From 9cd6cfc6e55e2de48dd12d3b6a1bf6d123d6d7f7 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 10 Dec 2024 06:32:22 -0800 Subject: [PATCH] feat: config validation and post (#60) * feat: config auth * add config route * fix tests * fix: dont double stringify * feat: allow put, config validation --- .eslintrc.cjs | 1 + src/config/handler.js | 27 +- src/schemas/Config.js | 81 ++++ src/types.d.ts | 95 ++++- src/utils/validation.d.ts | 117 ++++++ src/utils/validation.js | 398 +++++++++++++++++++ test/utils/validation.test.js | 694 ++++++++++++++++++++++++++++++++++ 7 files changed, 1409 insertions(+), 4 deletions(-) create mode 100644 src/schemas/Config.js create mode 100644 src/utils/validation.d.ts create mode 100644 src/utils/validation.js create mode 100644 test/utils/validation.test.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8ef1ccd..0f43789 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -62,5 +62,6 @@ module.exports = { globals: { __rootdir: true, __testdir: true, + globalThis: true, }, }; diff --git a/src/config/handler.js b/src/config/handler.js index 07fcd55..dda32ab 100644 --- a/src/config/handler.js +++ b/src/config/handler.js @@ -12,12 +12,15 @@ import { errorResponse } from '../utils/http.js'; import { assertAuthorization } from '../utils/auth.js'; +import { validate } from '../utils/validation.js'; +import ConfigSchema from '../schemas/Config.js'; /** * @param {Context} ctx + * @param {Request} req * @returns {Promise} */ -export default async function configHandler(ctx) { +export default async function configHandler(ctx, req) { const { method } = ctx.info; if (!['GET', 'POST'].includes(method)) { return errorResponse(405, 'method not allowed'); @@ -33,6 +36,24 @@ export default async function configHandler(ctx) { }); } - // TODO: validate config body, set config in kv - return errorResponse(501, 'not implemented'); + let json; + try { + json = await req.json(); + } catch { + return errorResponse(400, 'invalid JSON'); + } + + const errors = validate(json, ConfigSchema); + if (errors && errors.length) { + return errorResponse(400, 'invalid body', { errors }); + } + + // valid, persist it + await ctx.env.CONFIGS.put(ctx.config.siteKey, JSON.stringify(json)); + return new Response(JSON.stringify(json), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); } diff --git a/src/schemas/Config.js b/src/schemas/Config.js new file mode 100644 index 0000000..a7c461b --- /dev/null +++ b/src/schemas/Config.js @@ -0,0 +1,81 @@ +/* + * Copyright 2024 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. + */ + +/** @type {import("../utils/validation.js").AnySchema} */ +const AttributeOverrides = { + type: 'object', + properties: { + product: { + type: 'object', + properties: {}, + additionalProperties: { type: 'string' }, + }, + variant: { + type: 'object', + properties: {}, + additionalProperties: { type: 'string' }, + }, + }, +}; + +/** @type {import("../utils/validation.js").AnySchema} */ +const ConfigEntry = { + type: 'object', + properties: { + apiKey: { type: 'string' }, + magentoEnvironmentId: { type: 'string' }, + magentoWebsiteCode: { type: 'string' }, + storeCode: { type: 'string' }, + coreEndpoint: { type: 'string' }, + catalogEndpoint: { type: 'string' }, + storeViewCode: { type: 'string' }, + siteOverridesKey: { type: 'string' }, + host: { type: 'string' }, + helixApiKey: { type: 'string' }, + offerVariantURLTemplate: { type: 'string' }, + attributeOverrides: AttributeOverrides, + imageParams: { + type: 'object', + properties: {}, + additionalProperties: { type: 'string' }, + }, + pageType: { + type: 'string', + enum: ['product'], + }, + headers: { + type: 'object', + properties: {}, + additionalProperties: { type: 'string' }, + }, + imageRoles: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; + +/** @type {import("../utils/validation.js").AnySchema} */ +const Config = { + type: 'object', + properties: { + base: { + ...ConfigEntry, + required: [ + 'base', + ], + }, + }, + additionalProperties: ConfigEntry, +}; + +export default Config; diff --git a/src/types.d.ts b/src/types.d.ts index afa2b07..a87bbe5 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -3,10 +3,103 @@ import type { HTMLTemplate } from "./templates/html/HTMLTemplate.js"; import { JSONTemplate } from "./templates/json/JSONTemplate.js"; declare global { + /** + * The config for a single path pattern as stored in KV + */ + export interface RawConfigEntry { + /** + * API key for Core and Catalog + */ + apiKey?: string; + + /** + * Magento env ID + */ + magentoEnvironmentId?: string; + + /** + * Magento website code + */ + magentoWebsiteCode?: string; + + /** + * Store code + */ + storeCode?: string; + + /** + * Core Commerce endpoint + */ + coreEndpoint?: string; + + /** + * Catalog Service endpoint, defaults to non-sandbox + */ + catalogEndpoint?: string; + + /** + * Store view code + */ + storeViewCode?: string; + + /** + * Sitekey to use for overrides filename + */ + siteOverridesKey?: string; + + /** + * Host to use for absolute urls + */ + host?: string; + + /** + * API key for Helix, used for preview/publish during Helix Catalog API PUTs + */ + helixApiKey?: string; + + /** + * Headers to send with requests to Core and Catalog + */ + headers?: Record; + + /** + * Image roles to filter by, only include images with these roles + */ + imageRoles?: string[]; + + /** + * Attributes to override using a different attribute name + */ + attributeOverrides?: AttributeOverrides; + + /** + * Path pattern to use for offer variant URLs in JSON-LD + */ + offerVariantURLTemplate?: string; + + /** + * Additional parameters to add to image URLs as query params. + */ + imageParams?: Record; + + // required for non-base entries + pageType: 'product' | string; + } + + /** + * The config as stored in KV + * Each key, other than `base`, is a path pattern + * Path patterns use `{{arg}}` to denote `arg` as a path parameter + */ + export type RawConfig = { + base: RawConfigEntry; + [key: string]: RawConfigEntry; + } + /** * { pathPattern => Config } */ - export type ConfigMap = Record; + export type ConfigMap = Record; export interface AttributeOverrides { variant: { diff --git a/src/utils/validation.d.ts b/src/utils/validation.d.ts new file mode 100644 index 0000000..d4d5636 --- /dev/null +++ b/src/utils/validation.d.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2024 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. + */ + +export type FilterPrefixedKeys = { + [K in keyof TObj as K extends `${TPrefix}${infer I}` ? never : K]: TObj[K] +} + +export type PrefixKeys = { + [K in keyof TObj as K extends string ? `${TPrefix}${K}` : never]: TObj[K] +} + +/** + * AJV-like schema, with a slimmed down set of features + * + * AJV is >110kb and has many features we don't need on the edge, + * since the backend APIs should be validating the data too. + */ + +type InvertKey = 'not.'; + +type Uninvertable = 'type' | 'properties' | 'items' | 'additionalItems' | 'minItems' | 'maxItems' | 'min' | 'max' | + 'required' | 'additionalProperties' | 'minProperties' | 'maxProperties' | 'minLength' | 'maxLength' | 'nullable'; + +type InvertConditions< + T, + TUninvertible extends keyof Omit = never +> = PrefixKeys, Uninvertable | TUninvertible>, InvertKey> & T; + +export type BuiltinType = "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"; + +export type SchemaType = 'number' | 'integer' | 'string' | 'boolean' | 'array' | 'object' | 'null'; + +type AtLeastN = [n: number, conditions: Conditions[]]; + +interface _BaseSchema { + type: SchemaType; + enum?: any[]; + constant?: any; + nullable?: boolean; + atLeastN?: AtLeastN; +} + +type BaseSchema = InvertConditions<_BaseSchema>; + +interface _AnyNumberSchema extends BaseSchema { + min?: number; + max?: number; +} +type AnyNumberSchema = InvertConditions<_AnyNumberSchema>; + +interface _StringSchema extends BaseSchema { + type: "string"; + minLength?: number; + maxLength?: number; + pattern?: RegExp; +} +export type StringSchema = InvertConditions<_StringSchema>; + +export interface BooleanSchema extends BaseSchema { + type: "boolean"; +} + +export interface NullSchema extends BaseSchema { + type: "null"; +} + +export interface IntegerSchema extends AnyNumberSchema { + type: "integer"; +} + +export interface NumberSchema extends AnyNumberSchema { + type: "number"; +} + +type PropertySchema = InvertConditions; + +export interface ObjectSchema extends BaseSchema { + type: "object"; + properties: Record; + required?: string[]; + /** defaults to false */ + additionalProperties?: boolean | PropertySchema; + minProperties?: number; + maxProperties?: number; +} + +export interface ArraySchema extends BaseSchema { + items: AnySchema | AnySchema[]; + additionalItems?: boolean; + minItems?: number; + maxItems?: number; +} + +export type PrimitiveSchema = IntegerSchema | IntegerSchema | NumberSchema | StringSchema | BooleanSchema | NullSchema; + +export type AnySchema = PrimitiveSchema | ObjectSchema | ArraySchema; + +export type Conditions = Omit; + +export type UninvertedConditions = FilterPrefixedKeys, InvertKey>; + +export interface ValidationError { + path: string; + message: string; + details?: string; +} + +export declare function validate(obj: unknown, schema: AnySchema): ValidationError[] | undefined; \ No newline at end of file diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..74ed3e6 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,398 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck + +/* eslint-disable no-underscore-dangle */ + +/** @typedef {import("./validation.d.js").AnySchema} AnySchema */ +/** @typedef {import("./validation.d.js").SchemaType} SchemaType */ +/** @typedef {import("./validation.d.js").BuiltinType} BuiltinType */ +/** @typedef {import("./validation.d.js").Conditions} Conditions */ +/** @typedef {import("./validation.d.js").UninvertedConditions} UninvertedConditions */ +/** @typedef {import("./validation.d.js").ValidationError} ValidationError */ + +/** @type {Record} */ +const SCHEMA_DEF_TYPES = { + number: true, + integer: true, + string: true, + boolean: true, + array: true, + object: true, + null: true, +}; + +const NO_DOT_NOTATION_REGEX = /^[^a-zA-Z_]|.*(\s)|.*[^a-zA-Z0-9_]/; + +/** + * @param {unknown} obj + * @param {SchemaType} ptype + * @param {string} path + * @param {boolean} required + * @param {boolean} nullable + * @returns {ValidationError|undefined} + */ +const _validateType = (obj, ptype, path, required, nullable) => { + if (!SCHEMA_DEF_TYPES[ptype]) { + // eslint-disable-next-line no-console + console.error('invalid schema, unexpected type: ', ptype, path); + throw Error('invalid schema, unexpected type'); + } + + const actual = typeof obj; + const error = (a = actual) => ({ + message: 'invalid type', + details: `expected ${ptype}, got ${a}`, + path, + }); + + if (!required && actual === 'undefined') return undefined; + else if (actual === 'undefined') return error(); + else if ((nullable || ptype === 'null') && obj === null) return undefined; + else if (obj === null) return error('null'); + else if (ptype === 'array') { + if (!Array.isArray(obj)) return error(); + } else if (['number', 'integer'].includes(ptype)) { + if (actual !== 'number' && actual !== 'bigint') return error(); + else if (ptype === 'integer' && !Number.isInteger(obj)) { + return error('non-integer'); + } + } else if (ptype !== actual) return error(); + return undefined; +}; + +/** + * @template {keyof UninvertedConditions} T + * @param {any} obj + * @param {T} conditionType + * @param {UninvertedConditions[T]} condition + * @param {string} path + * @param {ValidationError[]} errors + * @returns {string|boolean|undefined} - error detail, error, no error + */ +const _checkCondition = (obj, conditionType, condition, path, errors) => { + switch (conditionType) { + case 'atLeastN': { + const [required, conditions] = condition; + let count = 0; + const newErrs = []; + conditions.find((one) => { + // eslint-disable-next-line no-use-before-define + const errs = checkConditions(obj, one, path); + if (!errs.length) { + count += 1; + } else { + newErrs.push(...errs); + } + return count >= required; + }); + if (count < required) { + errors.push(...newErrs); + return `${count} conditions passed`; + } + return undefined; + } + case 'constant': { + // TODO: deep equivalence if needed + return JSON.stringify(obj) !== JSON.stringify(condition); + } + case 'enum': { + let found; + condition.find((one) => { + found = _checkCondition(obj, 'constant', one, path, errors); + return !found; + }); + return found; + } + case 'pattern': { + return !condition.test(obj); + } + default: + // eslint-disable-next-line no-console + console.error('invalid schema, unexpected condition encountered: ', conditionType, obj); + throw Error('invalid schema, unexpected condition'); + } +}; + +/** + * @template {keyof Conditions} T + * @param {any} obj + * @param {T} key + * @param {Conditions[T]} condition + * @param {string} path + * @param {ValidationError[]} errors + * @returns {boolean} - whether to stop validation, something failed + */ +const checkCondition = (obj, key, condition, path, errors) => { + let invert = false; + let type = key; + if (type.startsWith('not.')) { + type = type.substring('not.'.length); + invert = true; + // for atLeastN, it becomes atMostN + // which is actually !(atLeastN-1) + if (type === 'atLeastN') { + // eslint-disable-next-line no-param-reassign + condition[0] -= 1; + } + } + const msg = _checkCondition(obj, type, condition, path, errors); + let failed = !!msg; + if (invert) failed = !failed; + // add error + if (failed) { + errors.push({ + path, + message: `condition '${key}' failed`, + ...(typeof msg === 'string' ? { details: msg } : {}), + }); + } + return failed; +}; + +/** + * @param {any} obj + * @param {Conditions} conditions + * @param {string} path + * @returns {ValidationError[]} + */ +const checkConditions = (obj, conditions, path) => { + const errors = []; + + Object.entries(conditions).find(([k, v]) => checkCondition(obj, k, v, path, errors)); + return errors; +}; + +/** + * Make a property key for the error path, + * using dot notation if possible + * + * @param {string} k + */ +const cleanPropertyPathKey = (k) => { + if (NO_DOT_NOTATION_REGEX.test(k)) { + return `['${k}']`; + } + return `.${k}`; +}; + +/** + * @param {unknown} obj - to validate + * @param {AnySchema} pschema - to match + * @param {string} [ppath=''] - to report location of errors + * @param {ValidationError[]} [errors=[]] - collection of errors + * @param {boolean} [prequired=true] + */ +const _validate = ( + obj, + pschema, + ppath = '$', + errors = [], + prequired = true, +) => { + if (pschema == null || typeof pschema !== 'object') { + throw Error('invalid schema'); + } + const { type, nullable, ...schema } = pschema; + const typeErr = _validateType(obj, type, ppath, prequired, nullable); + if (typeErr) { + errors.push(typeErr); + return errors; + } + + // nothing more to do + if (obj == null) return errors; + + /** @type {Conditions|undefined} */ + let conditions; + + /** + * @param {string} path + * @returns {(message: string, details?: string) => ValidationError[]} + */ + const error = (path) => (message, details) => { + errors.push({ + path, + message, + details, + }); + return errors; + }; + + // for current level object + const objErr = error(ppath); + + // check each type for it's uninvertible properties + // whatever is leftover are the conditions + switch (type) { + case 'array': { + const { + items, + minItems: min, + maxItems: max, + additionalItems, + ...rest + } = schema; + conditions = rest; + + const count = obj.length; + if (typeof min === 'number' && count < min) { + return objErr( + 'invalid array length', + `${count} items received, ${min} minimum`, + ); + } else if (typeof max === 'number' && count > max) { + return objErr( + 'invalid array length', + `${count} items received, ${max} minimum`, + ); + } + + const broke = !!obj.find((item, i) => { + const path = `${ppath}[${i}]`; + let itemSchema = items; + if (Array.isArray(items)) { + if (i >= items.length) { + if (!additionalItems) { + objErr( + 'additional items not allowed in tuple', + `${items.length - (i - 1)} additional items`, + ); + return true; + } + return false; + } else { + itemSchema = items[i]; + } + } + const prevErrs = errors.length; + _validate(item, itemSchema, path, errors); + // if an error was added, break early + return errors.length > prevErrs; + }); + + if (broke) return errors; + break; + } + case 'object': { + const { + properties, + additionalProperties, + minProperties: min, + maxProperties: max, + required = [], + ...rest + } = schema; + conditions = rest; + + const count = Object.keys(obj).length; + if (typeof min === 'number' && count < min) { + return objErr( + 'invalid object size', + `${count} properties received, ${min} minimum`, + ); + } else if (typeof max === 'number' && count > max) { + return objErr( + 'invalid object size', + `${count} properties received, ${max} maximum`, + ); + } + + const found = []; + const broke = !!Object.entries(obj).find(([k, v]) => { + const path = `${ppath}${cleanPropertyPathKey(k)}`; + const err = error(path); + let propSchema = properties[k]; + const propRequired = required.includes(k); + if (propRequired) found.push(k); + + if (!propSchema && !additionalProperties) { + return err( + 'additional properties not allowed in object', + `unexpected key '${k}' encountered`, + ); + } else if (!propSchema && typeof additionalProperties !== 'object') { + return false; + } else if (!propSchema) { + propSchema = additionalProperties; + } + + const prevErrs = errors.length; + _validate(v, propSchema, path, errors, propRequired); + // if an error was added, break early + return errors.length > prevErrs; + }); + + if (broke) return errors; + if (found.length < required.length) { + // find missing required props + const missing = required.filter((p) => !found.includes(p)); + return objErr('object missing required properties', `missing property keys: [${missing.map((k) => `'${k}'`).join(', ')}]`); + } + break; + } + case 'integer': + case 'number': { + const { min, max, ...rest } = schema; + conditions = rest; + + if (typeof min === 'number' && obj < min) { + return objErr('invalid number', `${obj} received, ${min} minimum`); + } else if (typeof max === 'number' && obj > max) { + return objErr('invalid number', `${obj} received, ${max} maximum`); + } + + break; + } + case 'string': { + const { minLength: min, maxLength: max, ...rest } = schema; + conditions = rest; + + const len = obj.length; + if (typeof min === 'number' && len < min) { + return objErr( + 'invalid string length', + `${len} characters received, ${min} minimum`, + ); + } else if (typeof max === 'number' && obj.length > max) { + return objErr( + 'invalid string length', + `${len} characters received, ${max} maximum`, + ); + } + + break; + } + case 'boolean': + case 'null': + conditions = schema; + break; + /* v8 ignore next 2 */ + default: + break; + } + + errors.push(...checkConditions(obj, conditions, ppath)); + return errors; +}; + +/** + * Light validation by schema definition + * @param {unknown} obj + * @param {AnySchema} schema + * @returns {ValidationError[]|undefined} + */ +export function validate(obj, schema) { + const errs = _validate(obj, schema); + return errs.length > 0 ? errs : undefined; +} diff --git a/test/utils/validation.test.js b/test/utils/validation.test.js new file mode 100644 index 0000000..657c7d2 --- /dev/null +++ b/test/utils/validation.test.js @@ -0,0 +1,694 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck + +import assert from 'assert'; +import { pruneUndefined as pruneUndef } from '../../src/utils/product.js'; +import { validate } from '../../src/utils/validation.js'; + +function check(val, schema, expectedErrors) { + const errs = validate(val, schema); + if (!expectedErrors) { + assert.ok(!errs); + } else { + assert.strictEqual(errs.length, expectedErrors.length); + errs.forEach((pactual, i) => { + const actual = { ...pactual }; + assert.deepStrictEqual( + expectedErrors[i], + pruneUndef({ + ...actual, + ...{ + details: undefined, + }, + }), + ); + }); + } +} + +function checkCases(cases, type) { + cases.forEach(({ value, errors, schema = {} }, i) => { + if (type && typeof schema.type === 'undefined') { + schema.type = type; + } + it(`-> case ${i}`, () => { + check(value, schema, errors); + }); + }); +} + +describe('util', () => { + describe('validate()', () => { + /** @type {Console} */ + let ogConsole; + before(() => { + ogConsole = { ...console }; + }); + afterEach(() => { + globalThis.console = ogConsole; + }); + + it('error cases', () => { + // swallow expected error logs + globalThis.console.error = () => {}; + + assert.throws(() => validate({}, null), Error('invalid schema')); + assert.throws(() => validate({}, undefined), Error('invalid schema')); + assert.throws(() => validate({}, 1), Error('invalid schema')); + assert.throws(() => validate({}, { type: 'badType' }), Error('invalid schema, unexpected type')); + assert.throws(() => validate({}, { type: 'object', badProperty: 1 }), Error('invalid schema, unexpected condition')); + }); + + describe('integer', () => { + /** @type {TestCase[]} */ + const cases = [ + // 0 + { + value: 1, + }, + // 1. float + { + value: 1.1, + errors: [ + { + message: 'invalid type', + path: '$', + }, + ], + }, + // 2. null, nullable + { + value: null, + schema: { + nullable: true, + }, + }, + // 3. null, not nullable + { + value: null, + schema: { + nullable: false, + }, + errors: [ + { + message: 'invalid type', + path: '$', + }, + ], + }, + // 4. in range + { + value: 7, + schema: { + min: 1, + max: 10, + }, + }, + // 5. below range + { + value: 0, + schema: { + min: 1, + max: 10, + }, + errors: [ + { + message: 'invalid number', + path: '$', + }, + ], + }, + // 6. above range + { + value: 11, + schema: { + min: 1, + max: 10, + }, + errors: [ + { + message: 'invalid number', + path: '$', + }, + ], + }, + // 7. in enum values, in range + { + value: 1, + schema: { + min: 1, + max: 10, + enum: [1, 2, 3], + }, + }, + // 8. at least N, valid + { + value: 1, + schema: { + atLeastN: [ + 2, + [ + { + constant: 1, + }, + { + constant: 2, + }, + { + enum: [1, 2], + }, + ], + ], + }, + }, + // 9. at least N, invalid + { + value: 1, + schema: { + atLeastN: [ + 2, + [ + { + constant: 1, + }, + { + constant: 2, + }, + { + enum: [2, 3], + }, + ], + ], + }, + errors: [ + { message: "condition 'constant' failed", path: '$' }, + { message: "condition 'enum' failed", path: '$' }, + { message: "condition 'atLeastN' failed", path: '$' }, + ], + }, + // 10. inverted rules, valid + { + value: 1, + schema: { + 'not.enum': [0, 2], + }, + }, + // 11. inverted rules, invalid + { + value: 1, + schema: { + 'not.enum': [0, 1, 2], + }, + errors: [ + { + message: "condition 'not.enum' failed", + path: '$', + }, + ], + }, + // 12. inverted atLeastN + { + value: 1, + schema: { + 'not.atLeastN': [ + 2, + [ + { + constant: 1, + }, + { + constant: 2, + }, + { + enum: [2, 3], + }, + ], + ], + }, + errors: [ + { + path: '$', + message: "condition 'not.atLeastN' failed", + }, + ], + }, + ]; + checkCases(cases, 'integer'); + }); + + describe('number', () => { + const cases = [ + // 0. constant, valid + { + value: 3.14159, + schema: { + constant: 3.14159, + }, + }, + // 1. constant, invalid + { + value: 2.71828, + schema: { + constant: 3.14159, + }, + errors: [ + { + message: "condition 'constant' failed", + path: '$', + }, + ], + }, + // 2. bigint, valid + { + // eslint-disable-next-line no-undef + value: BigInt(true), + }, + // 3. invalid type + { + value: 'abc', + errors: [{ message: 'invalid type', path: '$' }], + }, + ]; + checkCases(cases, 'number'); + }); + + describe('string', () => { + const cases = [ + // 0 + { + value: 'foo', + }, + // 1. minLength, valid + { + value: 'foo', + schema: { + minLength: 3, + }, + }, + // 2. minLength, invalid + { + value: 'foo', + schema: { + minLength: 4, + }, + errors: [ + { + message: 'invalid string length', + path: '$', + }, + ], + }, + // 3. maxLength, valid + { + value: 'foo', + schema: { + maxLength: 3, + }, + }, + // 4. maxLength, invalid + { + value: 'foo', + schema: { + maxLength: 2, + }, + errors: [ + { + message: 'invalid string length', + path: '$', + }, + ], + }, + // 5. pattern, valid + { + value: 'foo', + schema: { + pattern: /^foo$/, + }, + }, + // 6. pattern, invalid + { + value: 'foo2', + schema: { + pattern: /^foo$/, + }, + errors: [ + { + message: "condition 'pattern' failed", + path: '$', + }, + ], + }, + // 7. inverse pattern, valid + { + value: 'foo2', + schema: { + 'not.pattern': /^foo$/, + }, + }, + // 8. inverse pattern, invalid + { + value: 'foo', + schema: { + 'not.pattern': /^foo$/, + }, + errors: [ + { + message: "condition 'not.pattern' failed", + path: '$', + }, + ], + }, + ]; + checkCases(cases, 'string'); + }); + + describe('array', () => { + const cases = [ + // 0 + { + value: [1, 2, 3], + schema: { + items: { + type: 'number', + }, + }, + }, + // 1. invalid nth element + { + value: ['a', 'b', 3], + schema: { + items: { + type: 'string', + }, + }, + errors: [{ + message: 'invalid type', + path: '$[2]', + }], + }, + // 2. length, valid + { + value: [1, 2, 3], + schema: { + items: { + type: 'number', + }, + minItems: 3, + maxItems: 3, + }, + }, + // 3. length, too many + { + value: [1, 2, 3], + schema: { + items: { + type: 'number', + }, + maxItems: 2, + }, + errors: [{ + message: 'invalid array length', + path: '$', + }], + }, + // 4. length, too few + { + value: [1, 2, 3], + schema: { + items: { + type: 'number', + }, + minItems: 4, + }, + errors: [{ + message: 'invalid array length', + path: '$', + }], + }, + // 5. check elements are validated as schemas + { + value: ['a', 'a', 'b'], + schema: { + items: { + type: 'string', + pattern: /a/, + }, + }, + errors: [{ + message: "condition 'pattern' failed", + path: '$[2]', + }], + }, + // 6. tuple, allow additional, valid + { + value: [1, 2, 3], + schema: { + items: [{ type: 'number' }, { type: 'number' }], + additionalItems: true, + }, + }, + // 7. tuple, disallow additional, invalid + { + value: [1, 2, 3], + schema: { + items: [{ type: 'number' }, { type: 'number' }], + additionalItems: false, + }, + errors: [{ + message: 'additional items not allowed in tuple', + path: '$', + }], + }, + // 8. invalid type + { + value: 1, + errors: [{ message: 'invalid type', path: '$' }], + }, + ]; + checkCases(cases, 'array'); + }); + + describe('boolean', () => { + const cases = [ + // 0 + { + value: true, + }, + // 1 + { + value: false, + }, + // 2. null and nullable + { + value: null, + schema: { + nullable: true, + }, + }, + // 3. invalid type + { + value: 1, + errors: [{ message: 'invalid type', path: '$' }], + }, + ]; + checkCases(cases, 'boolean'); + }); + + describe('null', () => { + const cases = [ + // 0 + { + value: null, + }, + // 1. invalid type + { + value: undefined, + errors: [{ message: 'invalid type', path: '$' }], + }, + // 2. invalid type + { + value: 1, + errors: [{ message: 'invalid type', path: '$' }], + }, + ]; + checkCases(cases, 'null'); + }); + + describe('object', () => { + const cases = [ + // 0 + { + value: {}, + schema: { + properties: {}, + }, + }, + // 1. allow additional props + { + value: { foo: true }, + schema: { + properties: {}, + additionalProperties: true, + }, + }, + // 2. disallow additional props + { + value: { foo: true }, + schema: { + properties: {}, + additionalProperties: false, + }, + errors: [{ message: 'additional properties not allowed in object', path: '$.foo' }], + }, + // 3. limit property count, in range + { + value: { foo: true }, + schema: { + properties: {}, + minProperties: 0, + maxProperties: 2, + additionalProperties: true, + }, + }, + // 4. limit property count, above range + { + value: { foo: true, bar: 1 }, + schema: { + properties: {}, + minProperties: 0, + maxProperties: 1, + additionalProperties: true, + }, + errors: [{ message: 'invalid object size', path: '$' }], + }, + // 5. limit property count, below range + { + value: { foo: true }, + schema: { + properties: {}, + minProperties: 2, + maxProperties: 3, + additionalProperties: true, + }, + errors: [{ message: 'invalid object size', path: '$' }], + }, + // 6. invalid type + { + value: 1, + errors: [{ message: 'invalid type', path: '$' }], + }, + // 7. required properties, contains, valid + { + value: { foo: true }, + schema: { + properties: { + foo: { + type: 'boolean', + }, + }, + required: ['foo'], + }, + }, + // 8. required properties, does not contain, valid + { + value: { foo: undefined }, + schema: { + properties: { + foo: { + type: 'boolean', + }, + }, + }, + }, + // 9. required properties, invalid + { + value: { }, + schema: { + properties: { + foo: { + type: 'boolean', + }, + }, + required: ['foo'], + }, + errors: [{ message: 'object missing required properties', path: '$' }], + }, + // 10. nullable properties + { + value: { foo: null }, + schema: { + properties: { + foo: { + type: 'boolean', + nullable: true, + }, + }, + required: ['foo'], + }, + }, + // 11. additional props with defined schema, fails + { + value: { foo: true }, + schema: { + properties: {}, + additionalProperties: { type: 'string' }, + }, + errors: [{ message: 'invalid type', path: '$.foo' }], + }, + // 12. additional props with defined schema, passes + { + value: { foo: 'str' }, + schema: { + properties: {}, + additionalProperties: { type: 'string' }, + }, + }, + ]; + checkCases(cases, 'object'); + + // special cases + describe('special cases', () => { + it('simple path keys, uses dot notation', () => { + const [err] = validate({ foo: true }, { type: 'object', properties: { foo: { type: 'number' } } }); + assert.strictEqual(err.path, '$.foo'); + }); + + it('simple path keys, nested', () => { + const schema1 = { type: 'object', properties: { foo: { type: 'number' } } }; + const schema2 = { type: 'object', properties: { foo: schema1 } }; + const schema3 = { type: 'object', properties: { foo: schema2 } }; + const invalid = { foo: { foo: { foo: 'bad' } } }; + + const [err] = validate(invalid, schema3); + assert.strictEqual(err.path, '$.foo.foo.foo'); + }); + + it('complex path keys, uses bracket notation', () => { + const [err] = validate({ '1foo*cant be&variable': true }, { type: 'object', properties: { '1foo*cant be&variable': { type: 'number' } } }); + assert.strictEqual(err.path, '$[\'1foo*cant be&variable\']'); + }); + + it('combined path keys, nested', () => { + const schema1 = { type: 'object', properties: { root: { type: 'number' } } }; + const schema2 = { type: 'object', properties: { 'level 2': schema1 } }; + const schema3 = { type: 'object', properties: { foo: schema2 } }; + const invalid = { foo: { 'level 2': { root: 'bad' } } }; + + const [err] = validate(invalid, schema3); + assert.strictEqual(err.path, '$.foo[\'level 2\'].root'); + }); + }); + }); + }); +});