diff --git a/TEST_FAST.md b/TEST_FAST.md
index eb2013c601..fbea72db9c 100644
--- a/TEST_FAST.md
+++ b/TEST_FAST.md
@@ -10,6 +10,7 @@ export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError"
mvn clean install -T6 -DskipTests -Dpkg.skip=true
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
+mvn test -pl='msa/js-executor'
mvn test -pl dao -Dparallel=packages -DforkCount=4
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5
diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json
index 13b7c01d81..9cd29e35d5 100644
--- a/msa/js-executor/package.json
+++ b/msa/js-executor/package.json
@@ -7,7 +7,7 @@
"bin": "server.js",
"scripts": {
"pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-js-executor-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-js-executor-win.exe ./target/src && node install.js",
- "test": "echo \"Error: no test specified\" && exit 1",
+ "test": "mkdir -p target/surefire-reports && node --require ts-node/register --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=target/surefire-reports/TEST-js-executor.xml test/jsExecutor.test.ts",
"start": "nodemon --watch '.' --ext 'ts' --exec 'ts-node server.ts'",
"start-prod": "nodemon --watch '.' --ext 'ts' --exec 'NODE_ENV=production ts-node server.ts'",
"build": "tsc"
diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml
index 58404c0152..2f7434b472 100644
--- a/msa/js-executor/pom.xml
+++ b/msa/js-executor/pom.xml
@@ -116,6 +116,17 @@
--mutex network run pkg
+
+ yarn test
+
+ yarn
+
+ test
+
+ ${maven.test.skip}
+ --mutex network run test
+
+
diff --git a/msa/js-executor/test/jsExecutor.test.ts b/msa/js-executor/test/jsExecutor.test.ts
new file mode 100644
index 0000000000..7777030ce9
--- /dev/null
+++ b/msa/js-executor/test/jsExecutor.test.ts
@@ -0,0 +1,83 @@
+///
+/// Copyright © 2016-2026 The Thingsboard Authors
+///
+/// Licensed 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 CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { describe, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { JsExecutor } from '../api/jsExecutor';
+
+// describe('js-executor') groups all cases under
+// in the JUnit XML so they show up under that suite in TeamCity's Tests tab,
+// alongside thousands of Java tests.
+describe('js-executor', () => {
+
+test('sandbox isolates args from host realm (JVN#16937365)', async () => {
+ const exec = new JsExecutor(true);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ var F = args.constructor.constructor;
+ var p = F("return process")();
+ return p && p.mainModule ? 'reached-host' : 'isolated';
+ }`);
+ await assert.rejects(
+ exec.executeScript(script, ['{}', '{}', 'POST_TELEMETRY_REQUEST'], 5000),
+ /process is not defined/,
+ 'host process must not be reachable from inside the sandbox',
+ );
+});
+
+test('sandbox passes string args through unchanged', async () => {
+ const exec = new JsExecutor(true);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
+ }`);
+ const out = await exec.executeScript(script, ['hello', '{}', 'X'], 5000);
+ // Field-by-field: the returned object is owned by the sandbox realm, so
+ // its prototype is not the host Object.prototype and deepStrictEqual would
+ // reject it on prototype mismatch even when the values match.
+ assert.equal(out.msgIsString, true);
+ assert.equal(out.count, 3);
+ assert.equal(out.first, 'hello');
+});
+
+// The use_sandbox=false path is intentionally non-isolating: scripts compile
+// and run in the host realm via vm.compileFunction. The two tests below codify
+// that documented contract so any future behavior change shows up as a test
+// failure and forces a deliberate update of the docs and threat model.
+
+test('non-sandbox path does not isolate from host realm (documented contract)', async () => {
+ const exec = new JsExecutor(false);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ // Non-destructive host-reach probe: typeof process.platform is 'string'
+ // only if the host process object is reachable.
+ var F = args.constructor.constructor;
+ return F('return typeof process.platform')();
+ }`);
+ const out = await exec.executeScript(script, ['{}', '{}', 'X']);
+ assert.equal(out, 'string',
+ 'use_sandbox=false is documented as non-isolating; if this fails, the path was changed and docs/threat model must be updated');
+});
+
+test('non-sandbox path passes string args through unchanged', async () => {
+ const exec = new JsExecutor(false);
+ const script = await exec.compileScript(`function(msg, metadata, msgType){
+ return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
+ }`);
+ const out = await exec.executeScript(script, ['hello', '{}', 'X']);
+ assert.equal(out.msgIsString, true);
+ assert.equal(out.count, 3);
+ assert.equal(out.first, 'hello');
+});
+
+}); // describe('js-executor')
diff --git a/msa/js-executor/tsconfig.json b/msa/js-executor/tsconfig.json
index b633ffc768..a1f7b25466 100644
--- a/msa/js-executor/tsconfig.json
+++ b/msa/js-executor/tsconfig.json
@@ -9,5 +9,5 @@
"skipLibCheck": true,
"strictPropertyInitialization": false
},
- "exclude": ["node_modules", "target"]
+ "exclude": ["node_modules", "target", "test"]
}