feat: Add automated parameter validation framework (#2561)
This commit is contained in:
parent
e18441d430
commit
33c2207427
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -584,7 +584,8 @@
|
||||
"HTML To Text",
|
||||
"Generate Lorem Ipsum",
|
||||
"Numberwang",
|
||||
"XKCD Random Number"
|
||||
"XKCD Random Number",
|
||||
"Automated Validation Test Op"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
78
src/core/operations/AutomatedValidationTestOp.mjs
Normal file
78
src/core/operations/AutomatedValidationTestOp.mjs
Normal file
@ -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;
|
||||
@ -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,
|
||||
|
||||
121
tests/operations/tests/AutomatedValidation.mjs
Normal file
121
tests/operations/tests/AutomatedValidation.mjs
Normal file
@ -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": "" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user