From 3d2372d6710bf53bda7bdc28cf8ab1d763b24cf6 Mon Sep 17 00:00:00 2001 From: hindessc <76522921+hindessc@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:36:53 +0100 Subject: [PATCH 1/5] Add tests for validating blocks with fields for inputting text Part of #701 AI use: Claude was used to create an example test for a generator and to write code to detect blocks with text fields --- tests/security/color-validation.test.js | 185 ++++++++++++++++++++ tests/security/free-text-validation.test.js | 124 +++++++++++++ tests/security/index.test.js | 7 + tests/tests.html | 7 + 4 files changed, 323 insertions(+) create mode 100644 tests/security/color-validation.test.js create mode 100644 tests/security/free-text-validation.test.js create mode 100644 tests/security/index.test.js diff --git a/tests/security/color-validation.test.js b/tests/security/color-validation.test.js new file mode 100644 index 00000000..016956fd --- /dev/null +++ b/tests/security/color-validation.test.js @@ -0,0 +1,185 @@ +import { expect } from "chai"; +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; +import { defineMaterialsBlocks } from "../../blocks/materials.js"; +import { registerMaterialGenerators } from "../../generators/generators-material.js"; + + + +export function runColorValidationTests() { + describe("colour_from_string generator @security", function () { + this.timeout(5000); + + let workspace; + + before(function () { + if (!Blockly.Blocks["colour_from_string"]) { + defineMaterialsBlocks(); + } + registerMaterialGenerators(javascriptGenerator); + }); + + beforeEach(function () { + workspace = new Blockly.Workspace(); + }); + + afterEach(function () { + workspace.dispose(); + }); + + // colour_from_string is a value block, so blockToCode returns + // [code, order] — unwrap to just the code string. + function generate(block) { + javascriptGenerator.init(workspace); + const code = javascriptGenerator.blockToCode(block); + return Array.isArray(code) ? code[0] : code; + } + + it("generates a hash-prefixed colour string from a bare hex value", function () { + let block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "F000F0" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#F000F0"'); + block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "F00" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#F00"'); + }); + + it("doesn't generate a hash-prefixed colour string from a bare hex value that is an invalid length", function () { + let block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000F0F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F0" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000" }, + },workspace,); + expect(() => generate(block)).to.throw(); + block = Blockly.serialization.blocks.append({ type: "colour_from_string", + fields: { COLOR: "F000F" }, + },workspace,); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a hash-prefixed colour string from a bare hex value that is too short", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "80008" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + + + it("defaults to black when the field is empty", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"#000000"'); + }); + + + + it("generates a colour name string from a valid colour name", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "red" }, + }, + workspace, + ); + expect(generate(block)).to.equal('"red"'); + }); + + it("doesn't generate a hash-prefixed colour string from an invalid colour name", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "squirrel" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + + + it("doesn't generate a colour string from input with quotes", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "red" + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires evaluating code", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "#0000" + (255).toString(16) + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires invoking a Flock function", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" + randomColour() + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires invoking a Flock function later", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "000000" + changeColor(capsule1, { color: randomColour() }) + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + + it("doesn't generate a colour string from input that requires calling a disallowed function", function () { + const block = Blockly.serialization.blocks.append( + { + type: "colour_from_string", + fields: { COLOR: "" + eval("1") + "" }, + }, + workspace, + ); + expect(() => generate(block)).to.throw(); + }); + }); +} diff --git a/tests/security/free-text-validation.test.js b/tests/security/free-text-validation.test.js new file mode 100644 index 00000000..113f6eaa --- /dev/null +++ b/tests/security/free-text-validation.test.js @@ -0,0 +1,124 @@ +import { expect } from "chai"; +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; +import { initializeBlocks } from "../../main/blocklyinit.js"; + +initializeBlocks(); // registers all Flock custom blocks + generators + +const allBlockTypes = Object.keys(Blockly.Blocks).sort(); +function classifyField(field) { + if (field instanceof Blockly.FieldVariable) return "variable-name"; + if (field instanceof Blockly.FieldDropdown) return "dropdown"; + if (field instanceof Blockly.FieldTextInput) return "free-text"; + return null; // labels, checkboxes, numbers, images — not a text surface +} + +function scanRegisteredBlocks(workspace) { + const found = []; + for (const type of Object.keys(Blockly.Blocks)) { + let block; + try { + block = workspace.newBlock(type); + } catch { + continue; // blocks whose init needs a full UI environment + } + for (const input of block.inputList) { + for (const field of input.fieldRow) { + const kind = classifyField(field); + if (kind === "free-text") { + found.push(`${type}.${field.name}`); + } + } + } + block.dispose(); + } + return found.sort(); +} + +const TESTED_TEXT_FIELDS = [ + "text.TEXT", + "colour_from_string.COLOR", + "procedures_defnoreturn.NAME", + "procedures_defreturn.NAME", + "text_prompt.TEXT", +]; + +const payload = "\"; alert(1); //" + +export function runTextFieldValidationTests() { + describe("Text Field Validation @security", function () { + this.timeout(5000); + + let workspace = new Blockly.Workspace(); + + // colour_from_string is a value block, so blockToCode returns + // [code, order] — unwrap to just the code string. + function generate(block) { + javascriptGenerator.init(workspace); + const code = javascriptGenerator.blockToCode(block); + return Array.isArray(code) ? code[0] : code; + } + + for (const block of scanRegisteredBlocks(workspace)) { + it(block + " has a test", function () { + expect(TESTED_TEXT_FIELDS).to.include(block); + }); + } + + it("stringifies text field", function () { + const block = Blockly.serialization.blocks.append( + { + type: "text", + fields: { TEXT: payload }, + }, + workspace, + ); + expect(generate(block)).to.equal(JSON.stringify(payload)); + }); + + it("fixes name of procedure without return", function () { + const block = Blockly.serialization.blocks.append( + { + type: "procedures_defnoreturn", + fields: { NAME: payload }, + }, + workspace, + ); + block.argData_ = []; + expect(generate(block)).not.to.include(payload); + }); + + it("fixes name of procedure with return", function () { + const block = Blockly.serialization.blocks.append( + { + type: "procedures_defreturn", + fields: { NAME: payload }, + }, + workspace, + ); + block.argData_ = []; + expect(generate(block)).not.to.include(payload); + }); + + it("stringifies text prompt input", function () { + const block = Blockly.serialization.blocks.append( + { + type: "text_prompt", + fields: { TEXT: payload }, + }, + workspace, + ); + expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); + }); + + it("play tune block", function () { + const block = Blockly.serialization.blocks.append( + { + type: "play_tune", + }, + workspace, + ); + expect(generate(block)).to.equal("window.prompt('" + JSON.stringify(payload) + "')"); + }); + }); +} diff --git a/tests/security/index.test.js b/tests/security/index.test.js new file mode 100644 index 00000000..b6fb1a45 --- /dev/null +++ b/tests/security/index.test.js @@ -0,0 +1,7 @@ +import { runColorValidationTests } from "./color-validation.test.js"; +import { runTextFieldValidationTests } from "./free-text-validation.test.js"; + +export function runSecurityTests() { + runColorValidationTests(); + runTextFieldValidationTests(); +} diff --git a/tests/tests.html b/tests/tests.html index 500a7e31..55639b1a 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -485,6 +485,13 @@