diff --git a/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/README.md b/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/README.md new file mode 100644 index 0000000..4e85f19 --- /dev/null +++ b/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/README.md @@ -0,0 +1,19 @@ +# official-ajv-draft-04-meta-schema + +## What this file is + +`official-ajv-draft-04-meta-schema.json` is the canonical JSON Schema draft-04 meta-schema — the schema that describes what a valid draft-04 JSON Schema looks like. It is sourced from the `ajv-draft-04` npm package (`node_modules/ajv-draft-04/dist/refs/json-schema-draft-04.json`), which itself mirrors the official specification document published by the JSON Schema organization. + +## Why it exists here + +`@adobe/reactor-validator` uses AJV v8 to validate Tags extension descriptors against the turbine platform schemas (`@adobe/reactor-turbine-schemas`). Those schemas declare `"$schema": "http://json-schema.org/draft-04/schema#"`, meaning they are draft-04 JSON Schemas. + +AJV v8 ships meta-schemas for draft-06, draft-07, draft 2019-09, and draft 2020-12 — but not draft-04. Previously, the `ajv-draft-04` package was used as a dependency to supply both the draft-04 vocabulary and this meta-schema. That package declares `ajv@^8` as an optional peer dependency, which causes a runtime resolution failure for downstream consumers: npm nests `ajv@8` under this package's own `node_modules`, while `ajv-draft-04` gets hoisted to the consumer's top-level `node_modules` and resolves `require('ajv/dist/core')` against whatever AJV version the consumer has installed — which may be v6. + +To eliminate that broken dependency chain, `ajv-draft-04` was removed and AJV v8 is now configured directly. This file provides the draft-04 meta-schema so AJV can validate that the turbine platform schemas are well-formed draft-04 schemas at compile time, preserving the same correctness guarantees as before. + +## Source + +- npm package: `ajv-draft-04` (any version) +- path within package: `dist/refs/json-schema-draft-04.json` +- upstream specification: http://json-schema.org/draft-04/schema diff --git a/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/official-ajv-draft-04-meta-schema.json b/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/official-ajv-draft-04-meta-schema.json new file mode 100644 index 0000000..d96222e --- /dev/null +++ b/OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/official-ajv-draft-04-meta-schema.json @@ -0,0 +1,138 @@ +{ + "id": "http://json-schema.org/draft-04/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#"} + }, + "positiveInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveIntegerDefault0": { + "allOf": [{"$ref": "#/definitions/positiveInteger"}, {"default": 0}] + }, + "simpleTypes": { + "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] + }, + "stringArray": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "uniqueItems": true + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": {"$ref": "#/definitions/positiveInteger"}, + "minLength": {"$ref": "#/definitions/positiveIntegerDefault0"}, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "anyOf": [{"type": "boolean"}, {"$ref": "#"}], + "default": {} + }, + "items": { + "anyOf": [{"$ref": "#"}, {"$ref": "#/definitions/schemaArray"}], + "default": {} + }, + "maxItems": {"$ref": "#/definitions/positiveInteger"}, + "minItems": {"$ref": "#/definitions/positiveIntegerDefault0"}, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": {"$ref": "#/definitions/positiveInteger"}, + "minProperties": {"$ref": "#/definitions/positiveIntegerDefault0"}, + "required": {"$ref": "#/definitions/stringArray"}, + "additionalProperties": { + "anyOf": [{"type": "boolean"}, {"$ref": "#"}], + "default": {} + }, + "definitions": { + "type": "object", + "additionalProperties": {"$ref": "#"}, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": {"$ref": "#"}, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": {"$ref": "#"}, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [{"$ref": "#"}, {"$ref": "#/definitions/stringArray"}] + } + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + {"$ref": "#/definitions/simpleTypes"}, + { + "type": "array", + "items": {"$ref": "#/definitions/simpleTypes"}, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "allOf": {"$ref": "#/definitions/schemaArray"}, + "anyOf": {"$ref": "#/definitions/schemaArray"}, + "oneOf": {"$ref": "#/definitions/schemaArray"}, + "not": {"$ref": "#"} + }, + "dependencies": { + "exclusiveMaximum": ["maximum"], + "exclusiveMinimum": ["minimum"] + }, + "default": {} +} diff --git a/lib/createDraft4Ajv.js b/lib/createDraft4Ajv.js new file mode 100644 index 0000000..95bd9d4 --- /dev/null +++ b/lib/createDraft4Ajv.js @@ -0,0 +1,133 @@ +/*************************************************************************************** + * (c) 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + ****************************************************************************************/ + +// Factory that creates an AJV v8 instance configured for JSON Schema draft-04. +// +// AJV v8 speaks draft-07 natively and does not ship a draft-04 meta-schema. This module +// replicates what the ajv-draft-04 package provided, using only ajv's own internal modules, +// so downstream consumers are not exposed to ajv-draft-04's broken optional peer-dependency +// chain (see: https://github.com/adobe/reactor-validator/issues/XX). + +'use strict'; +var _ajvCore = require('ajv/dist/core'); +var AjvCore = _ajvCore.default; +var _ = _ajvCore._; +var str = _ajvCore.str; +var ops = require('ajv/dist/compile/codegen').operators; +var addAJVFormats = require('ajv-formats'); +var draft4MetaSchema = require('../OpenJSFoundation/draft04/official-ajv-draft-04-meta-schema/official-ajv-draft-04-meta-schema.json'); + +var DRAFT4_META_SCHEMA_ID = 'http://json-schema.org/draft-04/schema'; + +// JSON Schema draft-04 and draft-07 express "exclusive" range limits differently: +// +// draft-04: two keywords — a numeric "minimum" plus a boolean flag "exclusiveMinimum: true" +// e.g. { "minimum": 0, "exclusiveMinimum": true } means value must be > 0 +// +// draft-07: one keyword — "exclusiveMinimum" is itself the numeric limit +// e.g. { "exclusiveMinimum": 0 } means value must be > 0 +// +// AJV v8 speaks draft-07 natively, so it expects exclusiveMinimum/exclusiveMaximum to always +// be numbers. The draft-04 meta-schema we register (so AJV can validate that the turbine platform +// schemas are well-formed draft-04) uses the boolean flag style internally. Without custom +// handlers, AJV v8 throws "exclusiveMinimum value must be [number]" when it tries to compile +// that meta-schema. +// +// limitNumberDraft4 replaces AJV v8's built-in limitNumber. It handles "minimum"/"maximum" as +// numeric keywords but peeks at the parent schema for a boolean exclusiveMinimum/exclusiveMaximum +// flag, switching between strict (>) and non-strict (>=) comparisons accordingly. +// +// limitNumberExclusiveDraft4 registers "exclusiveMinimum"/"exclusiveMaximum" as valid boolean +// keywords so AJV does not reject them. It generates no data-validation code — the actual +// comparison logic lives in limitNumberDraft4 above. +var LIMIT_KWDS = { + maximum: { + exclusive: 'exclusiveMaximum', + ops: [ + { okStr: '<=', ok: ops.LTE, fail: ops.GT }, + { okStr: '<', ok: ops.LT, fail: ops.GTE }, + ], + }, + minimum: { + exclusive: 'exclusiveMinimum', + ops: [ + { okStr: '>=', ok: ops.GTE, fail: ops.LT }, + { okStr: '>', ok: ops.GT, fail: ops.LTE }, + ], + }, +}; + +function kwdOp(cxt) { + var kwd = LIMIT_KWDS[cxt.keyword]; + var opsIdx = (cxt.parentSchema && cxt.parentSchema[kwd.exclusive]) ? 1 : 0; + return kwd.ops[opsIdx]; +} + +var limitNumberDraft4 = { + keyword: Object.keys(LIMIT_KWDS), + type: 'number', + schemaType: 'number', + $data: true, + error: { + message: function(cxt) { return str`must be ${kwdOp(cxt).okStr} ${cxt.schemaCode}`; }, + params: function(cxt) { return _`{comparison: ${kwdOp(cxt).okStr}, limit: ${cxt.schemaCode}}`; }, + }, + code: function(cxt) { + cxt.fail$data(_`${cxt.data} ${kwdOp(cxt).fail} ${cxt.schemaCode} || isNaN(${cxt.data})`); + }, +}; + +var limitNumberExclusiveDraft4 = { + keyword: ['exclusiveMaximum', 'exclusiveMinimum'], + type: 'number', + schemaType: 'boolean', + code: function(cxt) { + var paired = { exclusiveMaximum: 'maximum', exclusiveMinimum: 'minimum' }; + if (cxt.parentSchema[paired[cxt.keyword]] === undefined) { + throw new Error(cxt.keyword + ' can only be used with ' + paired[cxt.keyword]); + } + }, +}; + +var draft4CoreVocab = [ + '$schema', 'id', '$defs', '$comment', 'definitions', + require('ajv/dist/vocabularies/core/ref').default, +]; + +var draft4ValidationVocab = [ + limitNumberDraft4, + limitNumberExclusiveDraft4, + require('ajv/dist/vocabularies/validation/multipleOf').default, + require('ajv/dist/vocabularies/validation/limitLength').default, + require('ajv/dist/vocabularies/validation/pattern').default, + require('ajv/dist/vocabularies/validation/limitProperties').default, + require('ajv/dist/vocabularies/validation/required').default, + require('ajv/dist/vocabularies/validation/limitItems').default, + require('ajv/dist/vocabularies/validation/uniqueItems').default, + { keyword: 'type', schemaType: ['string', 'array'] }, + { keyword: 'nullable', schemaType: 'boolean' }, + require('ajv/dist/vocabularies/validation/const').default, + require('ajv/dist/vocabularies/validation/enum').default, +]; + +module.exports = function createDraft4Ajv() { + var ajv = new AjvCore({ schemaId: 'id', strict: false, defaultMeta: DRAFT4_META_SCHEMA_ID }); + ajv.addVocabulary(draft4CoreVocab); + ajv.addVocabulary(draft4ValidationVocab); + ajv.addVocabulary(require('ajv/dist/vocabularies/applicator').default()); + ajv.addVocabulary(require('ajv/dist/vocabularies/format').default); + ajv.addVocabulary(['title', 'description', 'default']); + ajv.addMetaSchema(draft4MetaSchema, DRAFT4_META_SCHEMA_ID, false); + ajv.refs['http://json-schema.org/schema'] = DRAFT4_META_SCHEMA_ID; + addAJVFormats(ajv); + return ajv; +}; diff --git a/lib/validateSchema.js b/lib/validateSchema.js index 01ee3db..821bd5a 100644 --- a/lib/validateSchema.js +++ b/lib/validateSchema.js @@ -15,8 +15,7 @@ // Returns undefined if valid, or an error string if not. 'use strict'; -var Ajv = require("ajv-draft-04"); -var addAJVFormats = require("ajv-formats"); +var createDraft4Ajv = require('./createDraft4Ajv'); var schemas = { web: require('@adobe/reactor-turbine-schemas/schemas/extension-package-web.json'), @@ -24,6 +23,8 @@ var schemas = { mobile: require('@adobe/reactor-turbine-schemas/schemas/extension-package-mobile.json') }; +var ajv = createDraft4Ajv(); + var validateJsonStructure = function(extensionDescriptor) { var platform = extensionDescriptor.platform; if (!platform) { @@ -33,8 +34,6 @@ var validateJsonStructure = function(extensionDescriptor) { if (!extensionDescriptorSchema) { return 'unknown platform "' + platform + '".'; } - var ajv = new Ajv({ schemaId: 'auto', strict: false }); - addAJVFormats(ajv); if (!ajv.validate(extensionDescriptorSchema, extensionDescriptor)) { return ajv.errorsText(); } diff --git a/package-lock.json b/package-lock.json index c8bbbec..5e5ec04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "@adobe/reactor-validator", - "version": "3.0.0", + "version": "3.1.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adobe/reactor-validator", - "version": "3.0.0", + "version": "3.1.0-beta.1", "license": "Apache-2.0", "dependencies": { "@adobe/reactor-turbine-schemas": "^10.8.0", "ajv": "^8.12.0", - "ajv-draft-04": "^1.0.0", "ajv-formats": "^2.1.1" }, "bin": { @@ -1477,19 +1476,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ajv-formats": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", @@ -1681,9 +1667,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -2192,9 +2178,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3105,9 +3091,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3632,9 +3618,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4076,9 +4062,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3afb6d5..13cd24c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/reactor-validator", - "version": "3.0.0", + "version": "3.1.0-beta.1", "description": "Validates a Tags extension package.", "main": "lib/index.js", "exports": { @@ -26,7 +26,6 @@ "dependencies": { "@adobe/reactor-turbine-schemas": "^10.8.0", "ajv": "^8.12.0", - "ajv-draft-04": "^1.0.0", "ajv-formats": "^2.1.1" }, "engines": { diff --git a/tests/draft4-compliance/draft4-compliance.test.js b/tests/draft4-compliance/draft4-compliance.test.js new file mode 100644 index 0000000..32adf9d --- /dev/null +++ b/tests/draft4-compliance/draft4-compliance.test.js @@ -0,0 +1,224 @@ +/*************************************************************************************** + * (c) 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + ****************************************************************************************/ + +// Verifies that the AJV v8 instance created by createDraft4Ajv correctly handles JSON Schema +// draft-04 features. These tests exist specifically to guard against regressions if the +// vocabulary setup in createDraft4Ajv.js is changed — the turbine platform schemas do not +// exercise all draft-04 features, so integration tests alone are not sufficient. + +'use strict'; +var createDraft4Ajv = require('../../lib/createDraft4Ajv'); + +describe('draft-04 AJV compliance', () => { + var ajv; + + beforeEach(() => { + ajv = createDraft4Ajv(); + }); + + // ─── Boolean exclusiveMinimum ───────────────────────────────────────────────── + // draft-04 uses { minimum: N, exclusiveMinimum: true } to mean "strictly greater than N". + // AJV v8's built-in limitNumber does not support this — it expects exclusiveMinimum to be + // a number (draft-07 style). limitNumberDraft4 in createDraft4Ajv.js handles this correctly. + + describe('boolean exclusiveMinimum (draft-04 style)', () => { + it('rejects the boundary value when exclusiveMinimum is true', () => { + var schema = { type: 'number', minimum: 5, exclusiveMinimum: true }; + expect(ajv.validate(schema, 5)).toBe(false); + }); + + it('accepts a value above the boundary', () => { + var schema = { type: 'number', minimum: 5, exclusiveMinimum: true }; + expect(ajv.validate(schema, 6)).toBe(true); + }); + + it('accepts the boundary value when exclusiveMinimum is absent', () => { + var schema = { type: 'number', minimum: 5 }; + expect(ajv.validate(schema, 5)).toBe(true); + }); + + it('accepts the boundary value when exclusiveMinimum is false', () => { + var schema = { type: 'number', minimum: 5, exclusiveMinimum: false }; + expect(ajv.validate(schema, 5)).toBe(true); + }); + }); + + // ─── Boolean exclusiveMaximum ───────────────────────────────────────────────── + + describe('boolean exclusiveMaximum (draft-04 style)', () => { + it('rejects the boundary value when exclusiveMaximum is true', () => { + var schema = { type: 'number', maximum: 5, exclusiveMaximum: true }; + expect(ajv.validate(schema, 5)).toBe(false); + }); + + it('accepts a value below the boundary', () => { + var schema = { type: 'number', maximum: 5, exclusiveMaximum: true }; + expect(ajv.validate(schema, 4)).toBe(true); + }); + + it('accepts the boundary value when exclusiveMaximum is absent', () => { + var schema = { type: 'number', maximum: 5 }; + expect(ajv.validate(schema, 5)).toBe(true); + }); + + it('accepts the boundary value when exclusiveMaximum is false', () => { + var schema = { type: 'number', maximum: 5, exclusiveMaximum: false }; + expect(ajv.validate(schema, 5)).toBe(true); + }); + }); + + // ─── Draft-04 meta-schema registration ─────────────────────────────────────── + // The draft-04 meta-schema is registered so AJV validates that the turbine platform schemas + // are well-formed draft-04 at compile time. validateSchema() uses the registered meta-schema + // to check schema structure — it returns true for valid draft-04 and false for invalid. + + describe('draft-04 meta-schema registration', () => { + it('recognizes { minimum, exclusiveMinimum: true } as a valid draft-04 schema', () => { + var schema = { type: 'number', minimum: 0, exclusiveMinimum: true }; + expect(ajv.validateSchema(schema)).toBe(true); + }); + + it('rejects draft-07 style { exclusiveMinimum: 0 } as invalid draft-04', () => { + // In draft-04 exclusiveMinimum must be a boolean, not a number. + var schema = { exclusiveMinimum: 0 }; + expect(ajv.validateSchema(schema)).toBe(false); + }); + + it('rejects exclusiveMinimum without a sibling minimum', () => { + // The draft-04 meta-schema declares a dependency: exclusiveMinimum requires minimum. + var schema = { type: 'number', exclusiveMinimum: true }; + expect(ajv.validateSchema(schema)).toBe(false); + }); + + it('rejects exclusiveMaximum without a sibling maximum', () => { + var schema = { type: 'number', exclusiveMaximum: true }; + expect(ajv.validateSchema(schema)).toBe(false); + }); + }); + + // ─── $ref with definitions ──────────────────────────────────────────────────── + // draft-04 uses "definitions" (not "$defs") for reusable sub-schemas. + // This is the primary pattern used by the turbine platform schemas. + + describe('$ref with definitions', () => { + it('resolves $ref against definitions in the same schema', () => { + var schema = { + definitions: { + positiveNumber: { type: 'number', minimum: 1 } + }, + type: 'object', + properties: { + count: { '$ref': '#/definitions/positiveNumber' } + }, + required: ['count'] + }; + expect(ajv.validate(schema, { count: 5 })).toBe(true); + expect(ajv.validate(schema, { count: 0 })).toBe(false); + expect(ajv.validate(schema, {})).toBe(false); + }); + + it('resolves nested $ref chains through definitions', () => { + var schema = { + definitions: { + nonEmptyString: { type: 'string', minLength: 1 }, + label: { '$ref': '#/definitions/nonEmptyString' } + }, + type: 'object', + properties: { + name: { '$ref': '#/definitions/label' } + }, + required: ['name'] + }; + expect(ajv.validate(schema, { name: 'hello' })).toBe(true); + expect(ajv.validate(schema, { name: '' })).toBe(false); + }); + }); + + // ─── id keyword ─────────────────────────────────────────────────────────────── + // draft-04 uses "id" (not "$id") for schema identification. schemaId: 'id' in the AJV + // options tells AJV to use "id" as the canonical schema identifier. + + describe('id keyword', () => { + it('accepts a schema that uses id for identification', () => { + var schema = { + id: 'http://example.com/test-schema#', + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }; + expect(ajv.validate(schema, { name: 'hello' })).toBe(true); + expect(ajv.validate(schema, { name: 123 })).toBe(false); + expect(ajv.validate(schema, {})).toBe(false); + }); + }); + + // ─── additionalItems ────────────────────────────────────────────────────────── + // Controls whether array items beyond those defined in "items" (as a tuple) are allowed. + + describe('additionalItems', () => { + it('rejects extra array items when additionalItems is false', () => { + var schema = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + additionalItems: false + }; + expect(ajv.validate(schema, ['hello', 42])).toBe(true); + expect(ajv.validate(schema, ['hello', 42, 'extra'])).toBe(false); + }); + + it('allows extra items when additionalItems is a schema and they match', () => { + var schema = { + type: 'array', + items: [{ type: 'string' }], + additionalItems: { type: 'number' } + }; + expect(ajv.validate(schema, ['hello', 1, 2, 3])).toBe(true); + expect(ajv.validate(schema, ['hello', 'not-a-number'])).toBe(false); + }); + }); + + // ─── dependencies ───────────────────────────────────────────────────────────── + // draft-04 "dependencies" declares that the presence of one property requires + // either another property (property dependency) or a sub-schema to validate (schema dependency). + + describe('dependencies', () => { + it('requires a dependent property when its dependency is present', () => { + var schema = { + type: 'object', + dependencies: { + creditCard: ['billingAddress'] + } + }; + expect(ajv.validate(schema, { creditCard: '1234', billingAddress: '123 Main St' })).toBe(true); + expect(ajv.validate(schema, { creditCard: '1234' })).toBe(false); + expect(ajv.validate(schema, { billingAddress: '123 Main St' })).toBe(true); + expect(ajv.validate(schema, {})).toBe(true); + }); + + it('validates a schema dependency when the trigger property is present', () => { + var schema = { + type: 'object', + dependencies: { + name: { + properties: { name: { type: 'string', minLength: 1 } }, + required: ['name'] + } + } + }; + expect(ajv.validate(schema, { name: 'Alice' })).toBe(true); + expect(ajv.validate(schema, { name: '' })).toBe(false); + expect(ajv.validate(schema, {})).toBe(true); + }); + }); +});