From 33c2207427baec8713fed4401c451dffc8fbe4f3 Mon Sep 17 00:00:00 2001 From: MAN$I VERMA Date: Fri, 19 Jun 2026 17:29:04 +0530 Subject: [PATCH] feat: Add automated parameter validation framework (#2561) --- src/core/Ingredient.mjs | 66 ++++++++++ src/core/Operation.mjs | 19 +++ src/core/Recipe.mjs | 2 + src/core/config/Categories.json | 3 +- .../operations/AutomatedValidationTestOp.mjs | 78 +++++++++++ src/node/api.mjs | 3 + .../operations/tests/AutomatedValidation.mjs | 121 ++++++++++++++++++ tests/operations/tests/Hexdump.mjs | 2 +- 8 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/core/operations/AutomatedValidationTestOp.mjs create mode 100644 tests/operations/tests/AutomatedValidation.mjs diff --git a/src/core/Ingredient.mjs b/src/core/Ingredient.mjs index 0dd31707..4f81f83b 100644 --- a/src/core/Ingredient.mjs +++ b/src/core/Ingredient.mjs @@ -32,6 +32,8 @@ class Ingredient { this.min = null; this.max = null; this.step = 1; + this.integer = false; + this.allowEmpty = true; if (ingredientConfig) { this._parseConfig(ingredientConfig); @@ -59,6 +61,70 @@ class Ingredient { this.min = ingredientConfig.min; this.max = ingredientConfig.max; this.step = ingredientConfig.step; + this.integer = typeof ingredientConfig.integer !== "undefined" ? !!ingredientConfig.integer : false; + this.allowEmpty = typeof ingredientConfig.allowEmpty !== "undefined" ? !!ingredientConfig.allowEmpty : true; + } + + + /** + * Validates the given value against the constraints of this ingredient. + * + * @param {*} val + * @returns {boolean} + */ + validate(val) { + if (this.disabled) return true; + + let checkVal = val; + if (this.type === "toggleString" && val && typeof val === "object" && "string" in val) { + checkVal = val.string; + } + + // 1. check if empty + let isEmpty = false; + if (checkVal === null || checkVal === undefined || checkVal === "") { + isEmpty = true; + } else if (typeof checkVal.length === "number" && checkVal.length === 0) { + isEmpty = true; + } + + if (isEmpty) { + if (this.allowEmpty === false) { + throw new OperationError(`${this.name} cannot be empty.`); + } + return true; + } + + // 2. maxLength check + if (typeof this.maxLength === "number" && checkVal !== null && checkVal !== undefined) { + if (typeof checkVal === "string" && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + if (Array.isArray(checkVal) && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + if (checkVal instanceof Uint8Array && checkVal.length > this.maxLength) { + throw new OperationError(`${this.name} length cannot exceed ${this.maxLength}.`); + } + } + + // 3. number checks + if (this.type === "number") { + if (val === null || val === undefined || isNaN(val)) { + throw new OperationError(`${this.name} must be a number.`); + } + if (this.integer && !Number.isInteger(val)) { + throw new OperationError(`${this.name} must be an integer.`); + } + if (typeof this.min === "number" && val < this.min) { + throw new OperationError(`${this.name} must be greater than or equal to ${this.min}.`); + } + if (typeof this.max === "number" && val > this.max) { + throw new OperationError(`${this.name} must be less than or equal to ${this.max}.`); + } + } + + return true; } diff --git a/src/core/Operation.mjs b/src/core/Operation.mjs index 09058766..b35a49a6 100755 --- a/src/core/Operation.mjs +++ b/src/core/Operation.mjs @@ -189,11 +189,30 @@ class Operation { if (typeof ing.min === "number") conf.min = ing.min; if (typeof ing.max === "number") conf.max = ing.max; if (ing.step) conf.step = ing.step; + if (typeof ing.integer !== "undefined") conf.integer = ing.integer; + if (typeof ing.allowEmpty !== "undefined") conf.allowEmpty = ing.allowEmpty; return conf; }); } + /** + * Validates the operation's ingredients against their defined constraints. + * + * @param {Object[]} [args] - Optional list of argument values to validate. If not provided, validates the current ingredient values. + * @returns {boolean} - True if valid, throws an OperationError if invalid. + */ + validateIngredients(args) { + const values = args || this.ingValues; + this._ingList.forEach((ing, i) => { + if (i < values.length) { + ing.validate(values[i]); + } + }); + return true; + } + + /** * Returns the value of the Operation as it should be displayed in a recipe config. * diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs index 84c91d61..0886e994 100755 --- a/src/core/Recipe.mjs +++ b/src/core/Recipe.mjs @@ -212,6 +212,8 @@ class Recipe { self.sendProgressMessage(i + 1, this.opList.length); } + op.validateIngredients(op.ingValues); + if (op.flowControl) { // Package up the current state let state = { diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index ceecd005..bce89d4c 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -584,7 +584,8 @@ "HTML To Text", "Generate Lorem Ipsum", "Numberwang", - "XKCD Random Number" + "XKCD Random Number", + "Automated Validation Test Op" ] }, { diff --git a/src/core/operations/AutomatedValidationTestOp.mjs b/src/core/operations/AutomatedValidationTestOp.mjs new file mode 100644 index 00000000..315eb417 --- /dev/null +++ b/src/core/operations/AutomatedValidationTestOp.mjs @@ -0,0 +1,78 @@ +/** + * @author CyberChef + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * Automated validation test operation + */ +class AutomatedValidationTestOp extends Operation { + + /** + * AutomatedValidationTestOp constructor + */ + constructor() { + super(); + + this.name = "Automated Validation Test Op"; + this.module = "Default"; + this.description = "Operation used specifically to test automated parameter validation."; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Integer Number", + "type": "number", + "value": 5, + "min": 5, + "max": 10, + "integer": true + }, + { + "name": "Real Number", + "type": "number", + "value": 1.5, + "min": 1.5, + "max": 5.5 + }, + { + "name": "Non Empty String", + "type": "string", + "value": "hello", + "maxLength": 5, + "allowEmpty": false + }, + { + "name": "Empty Allowed String", + "type": "string", + "value": "", + "allowEmpty": true + }, + { + "name": "Non Empty Toggle String", + "type": "toggleString", + "value": { + "option": "Option A", + "string": "test" + }, + "toggleValues": ["Option A", "Option B"], + "allowEmpty": false + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + return "Success"; + } + +} + +export default AutomatedValidationTestOp; diff --git a/src/node/api.mjs b/src/node/api.mjs index 8002a8ac..f41feb23 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -193,6 +193,8 @@ export function _wrap(OpClass) { wrapped = async (input, args=null) => { const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + opInstance.validateIngredients(transformedArgs); + // SPECIAL CASE for Magic. Other flowControl operations will // not work because the opList is not passed in. if (isFlowControl) { @@ -229,6 +231,7 @@ export function _wrap(OpClass) { */ wrapped = (input, args=null) => { const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + opInstance.validateIngredients(transformedArgs); const result = opInstance.run(transformedInput, transformedArgs); return new NodeDish({ value: result, diff --git a/tests/operations/tests/AutomatedValidation.mjs b/tests/operations/tests/AutomatedValidation.mjs new file mode 100644 index 00000000..da84de11 --- /dev/null +++ b/tests/operations/tests/AutomatedValidation.mjs @@ -0,0 +1,121 @@ +/** + * Automated Parameter Validation tests + * + * @author CyberChef + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Automated Validation: Valid values", + input: "test", + expectedOutput: "Success", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Integer Number under min limit", + input: "test", + expectedOutput: "Integer Number must be greater than or equal to 5.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [4, 1.5, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Integer Number over max limit", + input: "test", + expectedOutput: "Integer Number must be less than or equal to 10.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [11, 1.5, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Integer Number not an integer", + input: "test", + expectedOutput: "Integer Number must be an integer.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5.5, 1.5, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Real Number under min limit", + input: "test", + expectedOutput: "Real Number must be greater than or equal to 1.5.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.4, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Real Number over max limit", + input: "test", + expectedOutput: "Real Number must be less than or equal to 5.5.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 5.6, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Non Empty String over maxLength limit", + input: "test", + expectedOutput: "Non Empty String length cannot exceed 5.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.5, "helloooo", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Non Empty String is empty", + input: "test", + expectedOutput: "Non Empty String cannot be empty.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.5, "", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Empty Allowed String is empty (allowed)", + input: "test", + expectedOutput: "Success", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }] + } + ] + }, + { + name: "Automated Validation: Non Empty Toggle String is empty", + input: "test", + expectedOutput: "Non Empty Toggle String cannot be empty.", + recipeConfig: [ + { + op: "Automated Validation Test Op", + args: [5, 1.5, "hello", "", { "option": "Option A", "string": "" }] + } + ] + } +]); diff --git a/tests/operations/tests/Hexdump.mjs b/tests/operations/tests/Hexdump.mjs index 12d04492..be071e23 100644 --- a/tests/operations/tests/Hexdump.mjs +++ b/tests/operations/tests/Hexdump.mjs @@ -129,7 +129,7 @@ TestRegister.addTests([ { name: "To Hexdump: Width too large", input: "H", - expectedOutput: "Width must be no more than 65536", + expectedOutput: "Width must be less than or equal to 65536.", recipeConfig: [ { op: "To Hexdump",