diff --git a/application/pom.xml b/application/pom.xml
index 67b61a5737..9489c627a1 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -572,6 +572,29 @@
${project.build.directory}
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+ verify-openapi-spec-generated
+ verify
+
+ enforce
+
+
+
+
+
+ ${project.build.directory}/openapi.json
+
+ OpenAPI spec was not generated — target/openapi.json is missing. The springdoc-openapi-maven-plugin logs HTTP failures but does not fail the build; scan the log above for "An error has occured" or a 5xx response from /v3/api-docs/thingsboard and fix the underlying issue (e.g. duplicate @Schema names rejected by SwaggerConfiguration).
+
+
+
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json
index 81cb2fdbb1..11d9bfbb9d 100644
--- a/application/src/main/data/json/system/widget_bundles/cards.json
+++ b/application/src/main/data/json/system/widget_bundles/cards.json
@@ -24,6 +24,7 @@
"cards.html_value_card",
"cards.markdown_card",
"cards.simple_card",
- "unread_notifications"
+ "unread_notifications",
+ "html_container"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json
index c8215d7fe1..e242bb00e7 100644
--- a/application/src/main/data/json/system/widget_bundles/html_widgets.json
+++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json
@@ -11,6 +11,7 @@
"widgetTypeFqns": [
"cards.html_card",
"cards.html_value_card",
- "cards.markdown_card"
+ "cards.markdown_card",
+ "html_container"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json
new file mode 100644
index 0000000000..25b9e5ac5d
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/html_container.json
@@ -0,0 +1,52 @@
+{
+ "fqn": "html_container",
+ "name": "HTML Container",
+ "deprecated": false,
+ "image": "tb-image;/api/images/system/html-container.png",
+ "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.",
+ "descriptor": {
+ "type": "static",
+ "sizeX": 9.5,
+ "sizeY": 5.5,
+ "resources": [],
+ "templateHtml": "\n",
+ "templateCss": "",
+ "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n",
+ "settingsDirective": "tb-html-container-widget-settings",
+ "hasBasicMode": true,
+ "basicModeDirective": "tb-html-container-basic-config",
+ "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}"
+ },
+ "resources": [
+ {
+ "link": "/api/images/system/html-container.png",
+ "title": "\"HTML Container\" system widget image",
+ "type": "IMAGE",
+ "subType": "IMAGE",
+ "fileName": "html-container.png",
+ "publicResourceKey": "0CBg8htTwiFsclrm44sIp5pQNS4MM35l",
+ "mediaType": "image/png",
+ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAADt9JREFUeAHtnU9oHccdx39OnCCVOrYcCESmCKxDITYkUB9aYkNb4tySQ0rsWxzowT600Bwa4h56yMW9tb05t6a3+Bjf7EAP8qEF6RCIAg2VSgtRoCVSHAfbKcbtfrrzy47Wu6NdvSe9Wfn7gZH2zZvdtzsz3/m785t9VrK/cE8W7vHCPWJiUhwI/2+ZmCS3C7dRuHt8QBzfKdwhkzgmzcHgxORAA09YqYn9iGOmcDcL96UJIe5bqQWE8iR/vlW4r0wIEYNIHvcm1X0TQsSgiUfU5xAigQQiRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCfabEP1gecTZwh2N/FhcdKkl/MVwjrNSuCvhnOxRDSL6csE2i6Mv8+Eag0ACEX1AGDM2Olxj3gaAmliiD4ejY28qbcXl6Phc4WbD8TiEtuMMoQYhQs8XbqrHOVPhnFkTO8lG5LqEuWsDI3eBkMFpr3q7tYtIpqJzJBIxEjkLxMUxFX0+0eG841aJYtokEjECuQqkLg64XrgbHc5dDGEdiURsmxwF0iaO690v8UB4iURsi9wEMg5xtJ0nkYje5CSQcYqj7XyJZDRGmSCs87QNgFwEshPiaLuORLI9GBXsMkiS4nZ0fMoGMFn4qJU2eW/a5NhJcTir4b8nyGOFe7Zwn1hehqI9Dr62PDhduGOF+3E4jiF9PrN+ENexyDgm/Z+yMm1WLS8OTVoguyEOZwgiyU0gpM2cbZ71ZsKP9PmL9ccnDGleTQc/F8e87Uy6j8IhvYsltsMoM+IIY58NhEnXIJTcfy3cc1a9F+Yl/Lir29O2uZlwp3DvFG7N8iG3GgRIBwTxVPhMBj8W/Pu+sk6T6hWrag/gna5lK2tyNbEa2A2RDEEckJtAVoP70MqMHPcfpoJ/H162zc01XmT8wPIUB2TTxCKjEllx1V3P1NtlKOLIHTJw/CbDtI3GouUpik3k1AfZCZFIHONlnPGWvTggt076OEUicYiRyXEUaxwikTjEWMh1mLdNJF1mck+axCHGRM7zIHWRfFq4jzqct2SVGCQOMRK5TxS6SBhiJKN3maByUayaxCFGZAhGG8jg7/Q75f8iuWxiJ2GYt/4KShODMM7QhqyaiD6sR8e8R3cxHKcMx/E+V5NIZDhO7Dlotq7b6HCNFRsAEojoi/fvtov3JwfDnIlcOBicyIM51SBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRIMfX3bG3xFqD+HVoXpe+Y82mZu6E//Vz/DwL/n6Nwe2TlymkE5t6Eqf1eHfD4Gsdw2dLjgJxe71vRn5sXI8dJdakk9HdwBqZnaW4HxfujD1o19f3474UrrFi+dl/HSKeRry2TloQ/+9bmTZvRP689XslET57hrZgylcJIgY3ogwIh5Ip3r/C9/QeTGk1INjO+ZqVhuRcFJgPfd5KuwGIghqdBVWI4WSDP+GzXxOScx9kJnJdoDo/YpXZUkSzaGLcYJmdNPG4pQD6g5U1OdshuKV2CqxfWymMlQb/QSyYyrkGuRAdxwnSBiXTQuGesbIqRyCUWNlv0jIwvB8Y9+V8ARU1OoJ51UoL7r6h6vUQ3v2v20AKr5wFEq9xvtAhPAlHlf9W4b6wMgHGsTxUbMabrPQlXCRYe/cO+WJwNHFft2qZ7kJwhD0brqMm1i5D9U01j+G4JRM7ARmbDP9i+EztTp+EESoy/svB3wUzFb53/5XIP3v2olUTSq8XrEyI+qaTsUnSwa2Nzog/Fu41K2sDYESK+EQ47P/oFjDpl9AZ3wjh3d/3BBkEWpOeD0Nbk942gDJlzTVEm3+uzMkulhiFtiH0uz39s0WvmgiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBLyseMC0q1EuDOlN14eBA6pBhEhADXKrcDdN5ITSIw8OqQYRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoE20BHjgLX0x63cItp3wWVznbXgBrtXvQQiRgFhsDnncUsbnPDtoAcnFAlEbBffzrmLJZYTITz71g9m8054tHCHTEYCcsEz29eWN6cKd8Y2F7DsXMs+9X8OjqbVvcI9Fb5/rHDPWblV9z9tGBwaSg0yW7iT0Wc2p19sCPM9q9rAbTSdK7pDPL8UfWarZ7aFrjefiGcEw064bA19OPjTJGMv+xUbAEMRCCXriZpfnMlJhAvWvbpHSNojfXuci44RB/GY2r0W4fzeSpHMBj9qn9/aAHa93SvDvGT6PlYJ5619j2/RDvHs8bZuZc3RJZPTrHonnGPhGidsAGgeRPQhztRto1JT1lxYIZIPos/HbADs1VEsSrZPa360m4+b6AuZ/VRwccZfagl7IRxftgdrl+XgR7hZGwB7VSCUVvXSjQT9rBZGpCETN/XtmjrYLg7P+BzXRUKcr4cwDKbQ1Mp6bmSvCuTpBj8SipEVEmiwM7u7SNPAB8Lw2fGYujjMKnHVRRIf02RbbrheNuwVgdSr8pe3CP++lUOQop1YHBQqTPKtNoRrEofTJhLndHAr4frZFVx7pZPOkG+fUohE0V4c7dRHqxiB6isOp62ZFsOo4i8sw35JTjUIY+Px0GufeQratr8r3NFEGCYavZM+HVz24/ATostoFVBTx5magZEj4Xgt+m42hKWWoDDzPgzp7e9xkR7MsWQ1P5KTQEaZmzgazndIhHqiDmLcPRPiTJ96d+pqCEu6vWtlGhyJzqMp+3rhPg/HUH+LAQH6TDvXORX8siDnPkgslq1eHyFhTkefEUd9pEVNqu54XBGPqdLcJwBJK2qM+dr3NMvof6wnrsNvULP48DDzIxJIA0RULIqLibBbDdGeMTEKPlfRpVC5Y+n06NI3XLVM50dy6qT3eXltyUZH8yDt+Csh1Nzz9hCTk0CoVru8ZUu4eqnEeX06dh+ZOugp4n7HC7bzMG/ltVVWcyK59UF8rD1euulQ4i9b83AjzTNGP07a1v0Vwi6YSMEckb9aQg1Ck5VO9k4UKvzG2eizBLIF212rQca/amIcUBhds2rClRFAhIJw1hrCNmXqGdu6eebrfGaia2XTQQctuRVtIAZqYx8dJBO/1BCOAu1KOI6H1k9Y/6H17GbTJRCRwicJ6Ycc7hDe36vqOxKVepVlouwr3Fzh/mEiBw6G/znaCPDJ2PpkLqOP8agitc4xS7/V4Hi/ElHlOGgy980fkQUHrRKJmDxzWlEoRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCSQQIRJIIEIkmKRAxrk/B9fquwaB8EM0BTRTc5N4Bpbj1uM7vqc9w6QWTBGJmPW5ZKOtIOM6Z62yEs7/y9btmqyzZkFQm2E0VsPdsfw2nSTe4ufDAEXTUmPWbrAu430bPyyTrS+1je+LdLhumS2f3Q65rCj0hTgsvtkInzFjeTf4swhnrSUcm5BeCtd5zcrEI8P43t1uRI7Ph8O1SNwr4b9ffzqEWbZqB6R1y3Ofb+49NpNEae5bPnjJ/oyVAnG7xfX4gPlwHt/dDceHrYoHi87rUlj4fbFYCkNwC+G68+E6cVzW09IS9+h5wZ8TpqPzreUe23637tfKpHa55eGopt16xvOF+8rK2oAdUFkHvT8cv164L62MgHo4IuRE8CMzs7LtEysj7s1w3g/Db5LwP7UykomYV6wS2U/C733fykyF/7NWRuS/g9sNuuxyS9ywNJVnd/vC3PfJ4P9LK3eZdQPUfM/z/DycTzji4l9WGoz+bvj+VDjnQLjedHQevzUf/JdCWH5rrXZfS+EcnuMHhftT4X5k5ZJddrnFCMSH1pzmbuT6llVphv/F8Dw8x89sc5rNhvtw49jz4RmWwm89G57nRSsLCtL8WM3vnrWTxS63NBG4URKNmydRl8MxD0pp9J6ViRGHwzQQAqO2IFLOhO9pUpyyqumxFL7nfDdyDbGpUkqoK1Y1/bAzS8RvWJ77evu2AUCTkqYMmd1LbZ6V5z4cviPsejjmGc9ZZfrIm6QUQMtReBfXuyEcme/kFveFUQfi+Ei4zt1wLd/xi0zsafd3K9PLbZSdDefcCPdxztJbVPwtXJfr/TccT4d7OBru9VJ079yTC+qadeyz5iAQEobmwMdW2bQi4ohYHt6r4NO1cN4kcoF5wiMOakXfTYrMgmgQyVbrnr3K3cq21qSpN7GAhPdapI4PYrj9qXinrVQzg/glTonjLnGCKG+H31qwqsNODeJxz38KLtLzvFUinInCbFjVhGrDw3pNB9509vXwZ6PrWfgdfpfCZM0qayyt7OYoFhn3fDh29XLjZFw2d6T08PaldwApDdxOVlM4qslz0bUoJUlUMoBHEiLbylavJ/7R6PfNhjPKRUZCHIvWXMoTH8TP5eCuWTe8j0KG7lKTes3F771olfHrq+F3KagQmzevfxPOI2297+K/G+8E5oVhk0h9Sze+9z3ZPf3es6qG5fqnwz3wu0esskTfym7WIEQM7c63rawSY3P4KP1O8Pft00hEmgxeIi7UwhEhPPhL4ZpuRJnEIFHYC/1iCEdJkSoBvWk1bVXTgMSmHfuF5WeJ8Xx0vBj95znfslIk3g4nLM0P4vXtEHbBuhnoI1NR+hM3PqDRpdDwtON3yKSvWmlBh3TDGjzNrXNWDRAsRs8VpxncCNdqM5K9Gp71V1Y9GwK9btXI2rpVgnvDqr0SO9mD3m2rJk1Ww7taEm8L56VHk/9WeFVvI9zXuBi3VZP6/W/3ecYxtzEzgt8oeWOmY7gm5ibRB7nb0a/rue4/ynXbwg/dwPW4nmccw9wbI/h1ue/U/iNdwjUiy4p7YDJL7Bx6F0uIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCBHLfJBQh6jzif/5j5QISIUTFtwt3G4F8XrgnrBSJahLxsIMG/KXRjX3Bk3eyfL22mBxek98yMSnoctCqouK49z8r21UdWLc82QAAAABJRU5ErkJggg==",
+ "public": true
+ }
+ ],
+ "scada": false,
+ "tags": [
+ "html",
+ "css",
+ "javascript",
+ "custom",
+ "script",
+ "code",
+ "container",
+ "angular",
+ "template",
+ "external resources",
+ "widget api",
+ "advanced",
+ "custom visualization",
+ "custom action",
+ "web",
+ "markup"
+ ]
+}
\ No newline at end of file
diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql
new file mode 100644
index 0000000000..790dc50b52
--- /dev/null
+++ b/application/src/main/data/upgrade/lts/schema_update.sql
@@ -0,0 +1,25 @@
+--
+-- 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.
+--
+
+-- LTS cumulative schema update file.
+-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.).
+-- This file is executed by SystemPatchApplier on every version increase within the LTS family.
+
+-- CALCULATED FIELD ADDITIONAL INFO ADDITION START
+
+ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
+
+-- CALCULATED FIELD ADDITIONAL INFO ADDITION END
diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index 67ef7f6ab1..4266c97303 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
@@ -36,6 +36,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.SmsService;
@@ -691,6 +692,10 @@ public class ActorSystemContext {
@Getter
private MqttClientSettings mqttClientSettings;
+ @Autowired(required = false)
+ @Getter
+ private TbHttpClientSettings tbHttpClientSettings;
+
@Getter
@Setter
private TbActorSystem actorSystem;
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
index a2dc9b50f4..b69719f1a0 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
@@ -485,7 +485,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
- state.init(false);
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isCfHasRelationPathQuerySource()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
@@ -494,6 +493,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
Map arguments = fetchArguments(ctx);
state.update(arguments, ctx);
+ state.init(false);
state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
states.put(ctx.getCfId(), state);
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index f93bb34af8..76f79bf1f9 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
@@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
@@ -1068,6 +1069,11 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getMqttClientSettings();
}
+ @Override
+ public TbHttpClientSettings getTbHttpClientSettings() {
+ return mainCtx.getTbHttpClientSettings();
+ }
+
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString());
diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
index 867efa67cc..db867234e2 100644
--- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.core.converter.ModelConverters;
+import io.swagger.v3.core.converter.ResolvedSchema;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.oas.models.Components;
@@ -166,6 +167,9 @@ public class SwaggerConfiguration {
if (StringUtils.isEmpty(apiVersion)) {
apiVersion = appVersion;
}
+ if (apiVersion != null && apiVersion.endsWith("-SNAPSHOT")) {
+ apiVersion = apiVersion.substring(0, apiVersion.length() - "-SNAPSHOT".length());
+ }
Info info = new Info()
.title(title)
@@ -373,13 +377,26 @@ public class SwaggerConfiguration {
._enum(Arrays.stream(ThingsboardErrorCode.values())
.map(ThingsboardErrorCode::getErrorCode)
.collect(Collectors.toList()));
- openAPI.getComponents()
- .addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema)
- .addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema)
- .addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema)
- .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema)
- .addSchemas("ThingsboardErrorCode", errorCodeSchema)
- .addSchemas("AiChatModelConfig", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(AiChatModelConfig.class)).schema);
+ Components components = openAPI.getComponents();
+ registerSchema(components, "LoginRequest", LoginRequest.class);
+ registerSchema(components, "LoginResponse", LoginResponse.class);
+ registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class);
+ registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class);
+ components.addSchemas("ThingsboardErrorCode", errorCodeSchema);
+ registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class);
+ }
+
+ private static void registerSchema(Components components, String name, Class> cls) {
+ ResolvedSchema resolved = ModelConverters.getInstance()
+ .readAllAsResolvedSchema(new AnnotatedType().type(cls));
+ components.addSchemas(name, resolved.schema);
+ if (resolved.referencedSchemas != null) {
+ resolved.referencedSchemas.forEach((refName, refSchema) -> {
+ if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) {
+ components.addSchemas(refName, refSchema);
+ }
+ });
+ }
}
private OperationCustomizer operationCustomizer() {
@@ -528,6 +545,12 @@ public class SwaggerConfiguration {
reorderSchemaProperties(schema, propOrder);
});
+ // Synthesize a request-body example for every schema that uses a discriminator.
+ // Without this, Swagger UI shows only the discriminator-property field for
+ // polymorphic types (the parent schema doesn't know which oneOf branch to pick).
+ // We resolve the first declared subtype and inline its full property tree.
+ schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas));
+
// Fix polymorphic request/response bodies: replace inline oneOf with base type $ref
paths.values().stream()
.flatMap(pathItem -> pathItem.readOperationsMap().values().stream())
@@ -600,6 +623,22 @@ public class SwaggerConfiguration {
if (baseType != null) {
return baseType;
}
+
+ // Check if other oneOf items extend this candidate via allOf (parent-child without discriminator)
+ if (candidate != null) {
+ boolean isParent = oneOfSchemas.stream()
+ .filter(s -> s.get$ref() != null && !s.get$ref().equals(ref))
+ .anyMatch(s -> {
+ String otherName = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1);
+ Schema> otherSchema = schemas.get(otherName);
+ return otherSchema != null && otherSchema.getAllOf() != null &&
+ otherSchema.getAllOf().stream().anyMatch(
+ a -> a.get$ref() != null && a.get$ref().endsWith("/" + refName));
+ });
+ if (isParent) {
+ return refName;
+ }
+ }
}
return null;
}
@@ -842,6 +881,145 @@ public class SwaggerConfiguration {
}
}
+ private static final int MAX_EXAMPLE_DEPTH = 4;
+
+ /**
+ * If {@code schema} has a discriminator, populate examples for the parent and every
+ * concrete subtype it maps to. Each subtype gets its own example with the discriminator
+ * field set to the mapping value that points at it, so fields typed as a specific
+ * subtype (e.g. {@code EntityView.id} → {@code EntityViewId}) resolve to a correct
+ * example without falling back to the parent's.
+ */
+ @SuppressWarnings("unchecked")
+ private void fillDiscriminatorExample(Schema> schema, Map allSchemas) {
+ var discriminator = schema.getDiscriminator();
+ if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) {
+ return;
+ }
+ // 1. Populate an example on each mapped subtype.
+ for (var entry : discriminator.getMapping().entrySet()) {
+ String discriminatorValue = entry.getKey();
+ String subtypeRef = entry.getValue();
+ String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1);
+ Schema> subtype = allSchemas.get(subtypeName);
+ if (subtype == null || subtype.getExample() != null) {
+ continue;
+ }
+ Map example = new LinkedHashMap<>();
+ buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0);
+ if (example.isEmpty()) {
+ continue;
+ }
+ example.put(discriminator.getPropertyName(), discriminatorValue);
+ subtype.setExample(example);
+ }
+ // 2. Mirror a subtype's example onto the parent so a field typed as the parent
+ // interface still gets a complete example. Prefer the subtype whose mapping key
+ // matches the example declared on the discriminator property itself
+ // (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not
+ // the alphabetically first AdminSettingsId). Fall back to the first mapping entry.
+ if (schema.getExample() == null) {
+ String preferredValue = null;
+ if (schema.getProperties() != null) {
+ Schema> discProp = (Schema>) schema.getProperties().get(discriminator.getPropertyName());
+ if (discProp != null && discProp.getExample() != null) {
+ preferredValue = discProp.getExample().toString();
+ }
+ }
+ String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null;
+ if (chosenRef == null) {
+ chosenRef = discriminator.getMapping().values().iterator().next();
+ }
+ String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1);
+ Schema> chosenSubtype = allSchemas.get(chosenSubtypeName);
+ if (chosenSubtype != null && chosenSubtype.getExample() != null) {
+ schema.setExample(chosenSubtype.getExample());
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void buildSchemaExample(String schemaName, Map allSchemas,
+ Map result, Set visited, int depth) {
+ if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) {
+ return;
+ }
+ Schema> schema = allSchemas.get(schemaName);
+ if (schema == null) {
+ return;
+ }
+ // Walk parents first so own properties (added later) override inherited entries.
+ if (schema.getAllOf() != null) {
+ String selfRef = "#/components/schemas/" + schemaName;
+ for (Schema> allOfElement : schema.getAllOf()) {
+ String ref = allOfElement.get$ref();
+ if (ref != null) {
+ String refName = ref.substring(ref.lastIndexOf('/') + 1);
+ buildSchemaExample(refName, allSchemas, result, visited, depth);
+ // If the parent uses a discriminator, this schema is one of its mapping
+ // targets — override the discriminator field with the value that points
+ // back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS").
+ Schema> parentSchema = allSchemas.get(refName);
+ if (parentSchema != null && parentSchema.getDiscriminator() != null
+ && parentSchema.getDiscriminator().getMapping() != null) {
+ parentSchema.getDiscriminator().getMapping().entrySet().stream()
+ .filter(e -> selfRef.equals(e.getValue()))
+ .map(Map.Entry::getKey)
+ .findFirst()
+ .ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value));
+ }
+ } else if (allOfElement.getProperties() != null) {
+ allOfElement.getProperties().forEach((k, v) ->
+ result.put(k, sampleValue((Schema>) v, allSchemas, visited, depth + 1)));
+ }
+ }
+ }
+ if (schema.getProperties() != null) {
+ schema.getProperties().forEach((k, v) ->
+ result.put(k, sampleValue((Schema>) v, allSchemas, visited, depth + 1)));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Object sampleValue(Schema> propSchema, Map allSchemas,
+ Set visited, int depth) {
+ if (propSchema == null) {
+ return null;
+ }
+ if (propSchema.getExample() != null) {
+ return propSchema.getExample();
+ }
+ String ref = propSchema.get$ref();
+ if (ref != null) {
+ String refName = ref.substring(ref.lastIndexOf('/') + 1);
+ Schema> refSchema = allSchemas.get(refName);
+ if (refSchema != null && refSchema.getExample() != null) {
+ return refSchema.getExample();
+ }
+ if (depth >= MAX_EXAMPLE_DEPTH) {
+ return Map.of();
+ }
+ Map nested = new LinkedHashMap<>();
+ buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1);
+ return nested;
+ }
+ if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) {
+ return propSchema.getEnum().get(0);
+ }
+ String type = propSchema.getType();
+ if (type == null) {
+ return null;
+ }
+ return switch (type) {
+ case "string" -> "string";
+ case "integer", "number" -> 0;
+ case "boolean" -> false;
+ case "array" -> List.of();
+ case "object" -> Map.of();
+ default -> null;
+ };
+ }
+
@SuppressWarnings("unchecked")
private void deduplicateAllOfProperties(Schema> schema, Map allSchemas, Set ownProps) {
if (schema.getAllOf() == null) {
diff --git a/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
new file mode 100644
index 0000000000..a5ad4e8742
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
@@ -0,0 +1,51 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.thingsboard.rule.engine.api.TbHttpClientSettings;
+import org.thingsboard.server.queue.util.TbRuleEngineComponent;
+
+@TbRuleEngineComponent
+@Component
+public class TbHttpClientSettingsComponent implements TbHttpClientSettings {
+
+ @Value("${actors.rule.external.http_client.max_parallel_requests:0}")
+ private int maxParallelRequests;
+
+ @Value("${actors.rule.external.http_client.max_pending_requests:0}")
+ private int maxPendingRequests;
+
+ @Value("${actors.rule.external.http_client.pool_max_connections:0}")
+ private int poolMaxConnections;
+
+ @Override
+ public int getMaxParallelRequests() {
+ return maxParallelRequests;
+ }
+
+ @Override
+ public int getMaxPendingRequests() {
+ return maxPendingRequests;
+ }
+
+ @Override
+ public int getPoolMaxConnections() {
+ return poolMaxConnections;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
index ae8305c070..8a113fb424 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
@@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
+import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
@@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController {
AlarmId alarmId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE);
alarmComment.setAlarmId(alarmId);
+ alarmComment.setType(AlarmCommentType.OTHER);
return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser());
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
index 50766106d5..4a20e2fcd2 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java
@@ -518,11 +518,11 @@ public class AssetController extends BaseController {
return checkNotNull(filteredResult);
}
- @ApiOperation(value = "Import the bulk of assets (processAssetsBulkImport)",
+ @ApiOperation(value = "Import the bulk of assets (processAssetBulkImport)",
notes = "There's an ability to import the bulk of assets using the only .csv file.")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN')")
@PostMapping("/asset/bulk_import")
- public BulkImportResult processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception {
+ public BulkImportResult processAssetBulkImport(@RequestBody BulkImportRequest request) throws Exception {
SecurityUser user = getCurrentUser();
return assetBulkImportService.processBulkImport(request, user);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/DomainController.java b/application/src/main/java/org/thingsboard/server/controller/DomainController.java
index 04e750d0e4..c34bbe6ac9 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java
@@ -93,19 +93,19 @@ public class DomainController extends BaseController {
tbDomainService.updateOauth2Clients(domain, oAuth2ClientIds, getCurrentUser());
}
- @ApiOperation(value = "Get Domain infos (getTenantDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH)
+ @ApiOperation(value = "Get Domain infos (getDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
@GetMapping(value = "/domain/infos")
- public PageData getTenantDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
- @RequestParam int pageSize,
- @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
- @RequestParam int page,
- @Parameter(description = "Case-insensitive 'substring' filter based on domain's name")
- @RequestParam(required = false) String textSearch,
- @Parameter(description = SORT_PROPERTY_DESCRIPTION)
- @RequestParam(required = false) String sortProperty,
- @Parameter(description = SORT_ORDER_DESCRIPTION)
- @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ public PageData getDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = "Case-insensitive 'substring' filter based on domain's name")
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION)
+ @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION)
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
accessControlService.checkPermission(getCurrentUser(), Resource.DOMAIN, Operation.READ);
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return domainService.findDomainInfosByTenantId(getTenantId(), pageLink);
diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
index a1d6f667f9..5e51e0bbb6 100644
--- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
+++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
@@ -116,19 +116,19 @@ public class OAuth2Controller extends BaseController {
return tbOauth2ClientService.save(oAuth2Client, getCurrentUser());
}
- @ApiOperation(value = "Get OAuth2 Client infos (findTenantOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
+ @ApiOperation(value = "Get OAuth2 Client infos (findOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = "/oauth2/client/infos")
- public PageData findTenantOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
- @RequestParam int pageSize,
- @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
- @RequestParam int page,
- @Parameter(description = "Case-insensitive 'substring' filter based on client's title")
- @RequestParam(required = false) String textSearch,
- @Parameter(description = SORT_PROPERTY_DESCRIPTION)
- @RequestParam(required = false) String sortProperty,
- @Parameter(description = SORT_ORDER_DESCRIPTION)
- @RequestParam(required = false) String sortOrder) throws ThingsboardException {
+ public PageData findOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
+ @RequestParam int pageSize,
+ @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
+ @RequestParam int page,
+ @Parameter(description = "Case-insensitive 'substring' filter based on client's title")
+ @RequestParam(required = false) String textSearch,
+ @Parameter(description = SORT_PROPERTY_DESCRIPTION)
+ @RequestParam(required = false) String sortProperty,
+ @Parameter(description = SORT_ORDER_DESCRIPTION)
+ @RequestParam(required = false) String sortOrder) throws ThingsboardException {
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return oAuth2ClientService.findOAuth2ClientInfosByTenantId(getTenantId(), pageLink);
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
index ee2e871d6e..9bcd756de0 100644
--- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
@@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;
+ @Value("${sql.query.key-filters-or-conditions.enabled:true}")
+ private boolean keyFiltersOrConditionsEnabled;
+
@Autowired(required = false)
private BuildProperties buildProperties;
@@ -173,6 +176,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
.orElse(false));
+ systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled);
return systemParams;
}
diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java
index 1796e959ab..b919b664e5 100644
--- a/application/src/main/java/org/thingsboard/server/controller/UserController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java
@@ -549,8 +549,7 @@ public class UserController extends BaseController {
userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), type, Arrays.asList(paths.split(",")));
}
- @ApiOperation(value = "Get information about last visited and starred dashboards (getUserDashboardsInfo)",
- notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @Hidden
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/user/dashboards")
public UserDashboardsInfo getUserDashboardsInfo() throws ThingsboardException {
@@ -558,6 +557,14 @@ public class UserController extends BaseController {
return userSettingsService.findUserDashboardsInfo(currentUser.getTenantId(), currentUser.getId());
}
+ @ApiOperation(value = "Get information about last visited and starred dashboards (getLastVisitedDashboards)",
+ notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+ @GetMapping(value = "/user/lastVisitedDashboards")
+ public UserDashboardsInfo getLastVisitedDashboards() throws ThingsboardException {
+ return getUserDashboardsInfo();
+ }
+
@ApiOperation(value = "Report action of User over the dashboard (reportUserDashboardAction)",
notes = "Report action of User over the dashboard. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
index cfc50543a1..a89d870bf0 100644
--- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
@@ -242,7 +242,7 @@ public class WidgetTypeController extends AutoCommitController {
@ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)",
notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
- @GetMapping(value = "/widgetsBundles/{widgetsBundleId}/widgetTypes")
+ @GetMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes")
public List getBundleWidgetTypes(
@Parameter(description = "Widget Bundle Id", required = true)
@PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException {
diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
index 48d3a907bc..cf56d051ca 100644
--- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
+++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
@@ -118,7 +118,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
private final ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>();
- private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>();
+ private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>();
private Cache pendingSessions;
@@ -631,7 +631,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0
&& UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser();
if (limitAllowed) {
@@ -675,7 +675,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke
}
}
if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
publicUserSessions.remove(sessionId);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
index 212d363280..15be6f3734 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
@@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override
public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) {
- ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
+ ChatModel langChainChatModel;
+ try {
+ langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
+ } catch (Throwable t) {
+ return FluentFuture.from(Futures.immediateFailedFuture(t));
+ }
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
index 28d696468c..8b569f052c 100644
--- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
+++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
@@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
+import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
@@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
@@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
+ validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey())
@@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
+ validateBaseUrl(providerConfig.endpoint());
return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion())
@@ -177,8 +181,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
if (chatModelConfig.frequencyPenalty() != null) {
generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
}
- if (chatModelConfig.frequencyPenalty() != null) {
- generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue());
+ if (chatModelConfig.presencePenalty() != null) {
+ generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
}
if (chatModelConfig.maxOutputTokens() != null) {
generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());
@@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
+ validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId())
@@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
return builder.build();
}
+ private static void validateBaseUrl(String url) {
+ SsrfProtectionValidator.validateUri(URI.create(url));
+ }
+
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
index 3ea6925695..6e9da33d0b 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
@@ -61,7 +61,7 @@ public interface CalculatedFieldCache {
void addOwnerEntity(TenantId tenantId, EntityId entityId);
- void evictEntity(EntityId entityId);
+ void evictOwnerEntity(EntityId entityId);
void evictOwner(EntityId owner);
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
index 8a7d38970f..3a989aaa60 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
@@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.server.actors.ActorSystemContext;
@@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.util.AfterStartUp;
@@ -45,7 +48,9 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -53,6 +58,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@@ -268,18 +274,25 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
@Override
public void updateOwnerEntity(TenantId tenantId, EntityId entityId) {
- evictEntity(entityId);
+ evictOwnerEntity(entityId);
addOwnerEntity(tenantId, entityId);
}
@Override
- public void evictEntity(EntityId entityId) {
+ public void evictOwnerEntity(EntityId entityId) {
ownerEntities.values().forEach(entities -> entities.remove(entityId));
}
@Override
public void evictOwner(EntityId owner) {
- ownerEntities.remove(owner);
+ Set removedEntities = ownerEntities.remove(owner);
+ if (removedEntities != null) {
+ Set removedCustomers = removedEntities
+ .stream()
+ .filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER)
+ .collect(Collectors.toSet());
+ removedCustomers.forEach(this::evictOwner);
+ }
}
private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) {
@@ -290,6 +303,113 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
});
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT_PROFILE:
+ if (event.getEvent() == ComponentLifecycleEvent.UPDATED) {
+ TenantProfileId tenantProfileId = new TenantProfileId(event.getEntityId().getId());
+ handleTenantProfileUpdate(tenantProfileId);
+ }
+ break;
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ evictTenantCfs(tenantId);
+ evictOwner(tenantId);
+ }
+ break;
+ case CUSTOMER:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) {
+ updateOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictOwner(event.getEntityId());
+ evictOwnerEntity(event.getEntityId());
+ }
+ break;
+ case DEVICE, ASSET:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) {
+ updateOwnerEntity(event.getTenantId(), event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictOwnerEntity(event.getEntityId());
+ evictEntityCfs(event.getEntityId());
+ }
+ break;
+ case DEVICE_PROFILE, ASSET_PROFILE:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ evictEntityCfs(event.getEntityId());
+ }
+ break;
+ case CALCULATED_FIELD:
+ if (event.getEvent() == ComponentLifecycleEvent.CREATED) {
+ addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
+ } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) {
+ updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId());
+ } else {
+ evict((CalculatedFieldId) event.getEntityId());
+ }
+ break;
+ }
+ }
+
+ private void evictTenantCfs(TenantId tenantId) {
+ var removedCfEntityIds = new HashSet();
+ var removedLinkEntityIds = new HashSet();
+ var toRemove = calculatedFields.entrySet().stream()
+ .filter(e -> e.getValue().getTenantId().equals(tenantId))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ toRemove.forEach(cfId -> {
+ CalculatedField cf = calculatedFields.remove(cfId);
+ List links = calculatedFieldLinks.remove(cfId);
+ if (links != null) {
+ links.forEach(link -> removedLinkEntityIds.add(link.entityId()));
+ }
+ calculatedFieldsCtx.remove(cfId);
+ if (cf != null) {
+ removedCfEntityIds.add(cf.getEntityId());
+ }
+ });
+ removedCfEntityIds.forEach(entityId -> {
+ entityIdCalculatedFields.compute(entityId, (k, cfs) -> {
+ if (cfs != null) {
+ cfs.removeIf(cf -> toRemove.contains(cf.getId()));
+ return cfs.isEmpty() ? null : cfs;
+ }
+ return null;
+ });
+ });
+ removedLinkEntityIds.forEach(entityId -> {
+ entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> {
+ if (links != null) {
+ links.removeIf(link -> toRemove.contains(link.calculatedFieldId()));
+ return links.isEmpty() ? null : links;
+ }
+ return null;
+ }));
+ });
+ }
+
+ private void evictEntityCfs(EntityId entityId) {
+ List cfs = entityIdCalculatedFields.remove(entityId);
+ if (cfs != null) {
+ var cfIds = new HashSet();
+ cfs.forEach(cf -> {
+ calculatedFields.remove(cf.getId());
+ calculatedFieldLinks.remove(cf.getId());
+ calculatedFieldsCtx.remove(cf.getId());
+ cfIds.add(cf.getId());
+ log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf);
+ });
+ entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId())));
+ }
+ entityIdCalculatedFieldLinks.remove(entityId);
+ }
+
private Lock getFetchLock(CalculatedFieldId id) {
return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock());
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
index 373480e161..1a0b609c5a 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
@@ -305,7 +305,8 @@ public class CalculatedFieldCtx implements Closeable {
public void setTenantProfileProperties() {
TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenantId);
if (tenantProfile == null) {
- throw new IllegalStateException("Tenant Profile not found for tenant: " + tenantId);
+ log.warn("[{}][{}][{}] Tenant Profile not found for tenant: {}. CF limits and thresholds will not be updated.", tenantId, entityId, cfId, tenantId);
+ return;
}
tenantProfile.getProfileConfiguration().ifPresent(config -> {
this.maxStateSize = config.getMaxStateSizeInKBytes() * 1024L;
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
index 1719c95f7a..f9c06dbda7 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
@@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
-import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
@@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
ruleState.setActive(null);
AlarmCondition condition = rule.getCondition();
if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) {
+ ruleState.cancelDurationCheckFuture();
reevalNeeded.set(true);
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
index 19c48272cc..ada94dacda 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
@@ -256,10 +256,7 @@ public class AlarmRuleState {
firstEventTs = 0L;
lastCheckTs = 0L;
duration = 0L;
- if (durationCheckFuture != null) {
- durationCheckFuture.cancel(true);
- durationCheckFuture = null;
- }
+ cancelDurationCheckFuture();
}
public void setDurationCheckFuture(ScheduledFuture> durationCheckFuture) {
@@ -270,6 +267,13 @@ public class AlarmRuleState {
this.durationCheckFuture = durationCheckFuture;
}
+ public void cancelDurationCheckFuture() {
+ if (durationCheckFuture != null) {
+ durationCheckFuture.cancel(true);
+ durationCheckFuture = null;
+ }
+ }
+
public boolean isEmpty() {
return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null;
}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
index 965d7105a9..d528f10899 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
@@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
-import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
index 96f58707b5..aaf066acdc 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
@@ -135,6 +135,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
}
+ public Path getWidgetBundlesDir() {
+ return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
+ }
+
public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@@ -207,7 +211,7 @@ public class InstallScripts {
public void loadSystemWidgets() {
log.info("Loading system widgets");
Map widgetsBundlesMap = new HashMap<>();
- Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
+ Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(
path -> {
diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
index 2babc3feeb..3932aa9dcf 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
@@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
public void upgradeDatabase() {
log.info("Updating schema...");
loadSql(getSchemaUpdateFile("basic"));
+ loadSql(getSchemaUpdateFile("lts"));
log.info("Schema updated.");
}
diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
index 28fa68d803..e0a9917509 100644
--- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
@@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
@Service
@Slf4j
@@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache {
}
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ Set toRemove = assetProfilesMap.values().stream()
+ .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId))
+ .map(AssetProfile::getId)
+ .collect(Collectors.toSet());
+ assetProfilesMap.keySet().removeAll(toRemove);
+ assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
+ profileListeners.remove(tenantId);
+ assetProfileListeners.remove(tenantId);
+ }
+ break;
+ }
+ }
+
private void notifyProfileListeners(AssetProfile profile) {
ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {
diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
index 6b356adf94..4729a8c118 100644
--- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
+++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.service.profile;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
@@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.dao.device.DeviceProfileService;
import org.thingsboard.server.dao.device.DeviceService;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
@Service
@Slf4j
@@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache {
}
}
+ @EventListener(ComponentLifecycleMsg.class)
+ public void onComponentLifecycleEvent(ComponentLifecycleMsg event) {
+ switch (event.getEntityId().getEntityType()) {
+ case TENANT:
+ if (event.getEvent() == ComponentLifecycleEvent.DELETED) {
+ TenantId tenantId = event.getTenantId();
+ Set toRemove = deviceProfilesMap.values().stream()
+ .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId))
+ .map(DeviceProfile::getId)
+ .collect(Collectors.toSet());
+ deviceProfilesMap.keySet().removeAll(toRemove);
+ devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue()));
+ profileListeners.remove(tenantId);
+ deviceProfileListeners.remove(tenantId);
+ }
+ break;
+ }
+ }
+
private void notifyProfileListeners(DeviceProfile profile) {
ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {
diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
index 50f0f0e75a..92d76693f9 100644
--- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
+++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
@@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME)));
- return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
+ return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) {
@@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
- return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
+ return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
@Override
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
index 24fa3aafb3..8a3f05f0b1 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
@@ -51,7 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory;
import org.thingsboard.server.queue.util.TbRuleEngineComponent;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
-import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.cf.CalculatedFieldStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
@@ -91,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService,
- CalculatedFieldCache calculatedFieldCache,
CalculatedFieldStateService stateService) {
- super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
+ super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.queueFactory = tbQueueFactory;
this.stateService = stateService;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
index 9bf2d027cb..519601c30a 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
@@ -86,7 +86,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
-import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.notification.NotificationSchedulerService;
import org.thingsboard.server.service.ota.OtaPackageStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@@ -179,9 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumer = requests.remove(requestId);
if (consumer != null) {
- consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), restApiCallResponseMsg.getResponse(), TbMsgCallback.EMPTY));
+ consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), TbMsgCallback.EMPTY));
} else {
log.trace("[{}] Unknown or stale rest api call response received", requestId);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
index c2fe743bb4..5b4287acd1 100644
--- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
@@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
- return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
+ return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
}
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
index cf8d9b1139..27bd88fc1a 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
@@ -64,7 +64,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS
private final RateLimitService rateLimitService;
private final TbLogEntityActionService logEntityActionService;
- protected static final List SUPPORTED_ENTITY_TYPES = List.of(
+ public static final List SUPPORTED_ENTITY_TYPES = List.of(
EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE,
EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET,
EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE,
@@ -131,7 +131,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS
@Override
public Comparator getEntityTypeComparatorForImport() {
- return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf);
+ return Comparator.comparingInt(type -> {
+ int index = SUPPORTED_ENTITY_TYPES.indexOf(type);
+ return index >= 0 ? index : Integer.MAX_VALUE;
+ });
}
@SuppressWarnings("unchecked")
diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
index d80961923d..a256888107 100644
--- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
+++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
@@ -28,7 +28,10 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
@@ -40,7 +43,12 @@ import java.net.URL;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@@ -48,7 +56,7 @@ import java.util.stream.Stream;
/**
* Runs at application startup and applies no-downtime data updates
- * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1).
+ * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1).
*/
@Slf4j
@Component
@@ -64,6 +72,8 @@ public class SystemPatchApplier {
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService;
+ private final WidgetsBundleService widgetsBundleService;
+ private final ImageService imageService;
@PostConstruct
private void init() {
@@ -91,11 +101,19 @@ public class SystemPatchApplier {
}
try {
+ updateLtsSqlSchema();
+
updateSqlViews();
log.info("Updated sql database views");
- int updated = updateWidgetTypes();
- log.info("Updated {} widget types", updated);
+ WidgetTypeStats widgetStats = updateWidgetTypes();
+ log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated());
+
+ int updatedBundles = updateWidgetBundles();
+ log.info("System widget bundles: {} updated", updatedBundles);
+
+ int createdImages = createMissingSystemImages();
+ log.info("Created {} new system images", createdImages);
schemaSettingsService.updateSchemaVersion();
log.info("System data patch update completed successfully");
@@ -119,17 +137,37 @@ public class SystemPatchApplier {
return false;
}
- if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) {
+ if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
return false;
}
- log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
+ log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
return true;
}
- private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) {
- return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor
- && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch;
+ private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
+ if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
+ return false;
+ }
+ if (packageVersion.maintenance != dbVersion.maintenance) {
+ return packageVersion.maintenance > dbVersion.maintenance;
+ }
+ return packageVersion.patch > dbVersion.patch;
+ }
+
+ private void updateLtsSqlSchema() {
+ Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
+ if (!Files.exists(sqlFile)) {
+ log.trace("LTS schema update file does not exist: {}", sqlFile);
+ return;
+ }
+ try {
+ String sql = Files.readString(sqlFile);
+ jdbcTemplate.execute(sql);
+ log.info("Applied LTS SQL schema update from {}", sqlFile);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
+ }
}
private void updateSqlViews() {
@@ -142,20 +180,24 @@ public class SystemPatchApplier {
}
}
- private int updateWidgetTypes() {
+ private WidgetTypeStats updateWidgetTypes() {
+ AtomicInteger created = new AtomicInteger();
AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir);
- return 0;
+ return new WidgetTypeStats(0, 0);
}
try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(
path -> {
try {
- if (updateWidgetTypeFromFile(path)) {
+ WidgetTypeChange change = updateWidgetTypeFromFile(path);
+ if (change == WidgetTypeChange.CREATED) {
+ created.incrementAndGet();
+ } else if (change == WidgetTypeChange.UPDATED) {
updated.incrementAndGet();
}
} catch (Exception e) {
@@ -166,18 +208,26 @@ public class SystemPatchApplier {
);
}
- return updated.get();
+ return new WidgetTypeStats(created.get(), updated.get());
}
- private boolean updateWidgetTypeFromFile(Path filePath) {
+ private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
+ return saveOrUpdateSystemWidgetType(fileWidgetType);
+ }
+
+ private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) {
String fqn = fileWidgetType.getFqn();
+ if (fqn == null || fqn.isBlank()) {
+ throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName());
+ }
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) {
- // We expect only update here, so it's probably never happening, but for test purpose leave it like this:
- throw new RuntimeException("Widget type not found: " + fqn);
+ widgetTypeService.saveWidgetType(fileWidgetType);
+ log.trace("Created widget type: {}", fqn);
+ return WidgetTypeChange.CREATED;
}
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription());
@@ -185,11 +235,128 @@ public class SystemPatchApplier {
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn);
- return true;
+ return WidgetTypeChange.UPDATED;
}
log.trace("Widget type unchanged: {}", fqn);
- return false;
+ return WidgetTypeChange.UNCHANGED;
+ }
+
+ private int updateWidgetBundles() {
+ AtomicInteger updated = new AtomicInteger();
+ Path widgetBundlesDir = installScripts.getWidgetBundlesDir();
+
+ if (!Files.exists(widgetBundlesDir)) {
+ log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir);
+ return 0;
+ }
+
+ try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
+ dirStream.forEach(path -> {
+ try {
+ if (processWidgetBundleFile(path)) {
+ updated.incrementAndGet();
+ }
+ } catch (Exception e) {
+ log.error("Unable to process widgets bundle from json: [{}]", path);
+ throw new RuntimeException("Unable to process widgets bundle from json", e);
+ }
+ });
+ }
+
+ return updated.get();
+ }
+
+ private boolean processWidgetBundleFile(Path filePath) {
+ JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile());
+ if (bundleJson == null || !bundleJson.has("widgetsBundle")) {
+ throw new RuntimeException("Invalid widgets bundle json: " + filePath);
+ }
+ WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class);
+ String alias = fileBundle.getAlias();
+ if (alias == null || alias.isBlank()) {
+ throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath);
+ }
+
+ WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
+ if (existingBundle == null) {
+ log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias);
+ return false;
+ }
+
+ if (bundleJson.has("widgetTypes")) {
+ throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " +
+ "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath);
+ }
+ List fileWidgetFqns = new ArrayList<>();
+ if (bundleJson.has("widgetTypeFqns")) {
+ bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText()));
+ }
+
+ boolean changed = false;
+ if (isWidgetsBundleChanged(existingBundle, fileBundle)) {
+ existingBundle.setTitle(fileBundle.getTitle());
+ existingBundle.setDescription(fileBundle.getDescription());
+ existingBundle.setImage(fileBundle.getImage());
+ existingBundle.setOrder(fileBundle.getOrder());
+ existingBundle.setScada(fileBundle.isScada());
+ widgetsBundleService.saveWidgetsBundle(existingBundle);
+ log.trace("Updated widgets bundle metadata: {}", alias);
+ changed = true;
+ }
+
+ List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
+ LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns);
+ if (mergedFqns.addAll(fileWidgetFqns)) {
+ widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns));
+ log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias);
+ changed = true;
+ }
+ return changed;
+ }
+
+ private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) {
+ // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores
+ // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare
+ // would always report a diff and re-save every system bundle on every patch run. Image content changes
+ // are out of scope for the patch applier — full reinstall covers them.
+ return !Objects.equals(existing.getTitle(), file.getTitle())
+ || !Objects.equals(existing.getDescription(), file.getDescription())
+ || !Objects.equals(existing.getOrder(), file.getOrder())
+ || existing.isScada() != file.isScada();
+ }
+
+ private int createMissingSystemImages() {
+ AtomicInteger created = new AtomicInteger();
+ Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images");
+
+ if (!Files.exists(imagesDir)) {
+ log.warn("System images directory does not exist: {}", imagesDir);
+ return 0;
+ }
+
+ Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+
+ try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) {
+ dirStream.forEach(path -> {
+ String resourceKey = path.getFileName().toString();
+ if (existingKeys.contains(resourceKey)) {
+ log.trace("System image already exists, skipping: {}", resourceKey);
+ return;
+ }
+ try {
+ byte[] data = Files.readAllBytes(path);
+ imageService.createOrUpdateSystemImage(resourceKey, data);
+ created.incrementAndGet();
+ log.trace("Created system image: {}", resourceKey);
+ } catch (Exception e) {
+ log.error("Unable to create system image from file: [{}]", path);
+ throw new RuntimeException("Unable to create system image " + resourceKey, e);
+ }
+ });
+ }
+
+ return created.get();
}
private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) {
@@ -287,4 +454,8 @@ public class SystemPatchApplier {
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
+ public record WidgetTypeStats(int created, int updated) {}
+
+ private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
index 4b1a81dd2d..27a8ca275c 100644
--- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
+++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
@@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService {
private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>();
- private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
+ private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>();
private ExecutorService executor;
@@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
- private void processSessionClose(WebSocketSessionRef sessionRef) {
+ void processSessionClose(WebSocketSessionRef sessionRef) {
var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef);
if (tenantProfileConfiguration != null) {
String sessionId = "[" + sessionRef.getSessionId() + "]";
@@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
publicUserSessions.removeIf(subId -> subId.startsWith(sessionId));
}
@@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
- private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) {
+ boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) {
var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef);
if (tenantProfileConfiguration == null) return true;
@@ -401,9 +401,11 @@ public class DefaultWebSocketService implements WebSocketService {
}
}
if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) {
- Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
+ Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
- if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) {
+ if (cmd.isUnsubscribe()) {
+ publicUserSessions.remove(subId);
+ } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) {
publicUserSessions.add(subId);
} else {
log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached"
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 15d6afb37d..505ac574ae 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -59,6 +59,14 @@ server:
http2:
# Enable/disable HTTP/2 support
enabled: "${HTTP2_ENABLED:true}"
+ # HTTP response compression
+ compression:
+ # Enable/disable HTTP response compression
+ enabled: "${HTTP_COMPRESSION_ENABLED:false}"
+ # Minimum size (in bytes) required for a response before compression is applied
+ min_response_size: "${HTTP_COMPRESSION_MIN_RESPONSE_SIZE:2048}"
+ # Comma-separated list of MIME types that should be compressed
+ mime_types: "${HTTP_COMPRESSION_MIME_TYPES:text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml}"
# Log errors with stacktrace when REST API throws an exception with the message "Please contact sysadmin"
log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}"
ws:
@@ -469,6 +477,11 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
+ entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order.
+ query:
+ key-filters-or-conditions:
+ # Enable/disable OR conditions between key filters in entity data queries
+ enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}"
postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"
@@ -594,6 +607,18 @@ actors:
# Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24).
# Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp"
ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}"
+ http_client:
+ # Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance.
+ # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins).
+ max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}"
+ # Server-level ceiling for the pending-request queue depth per external HTTP rule node instance.
+ # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction.
+ max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}"
+ # Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance.
+ # Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2
+ # (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections.
+ # 0 = use reactor-netty default.
+ pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}"
rpc:
# Maximum number of persistent RPC call retries in case of failed request delivery.
max_retries: "${ACTORS_RPC_MAX_RETRIES:5}"
@@ -1439,6 +1464,15 @@ transport:
branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.3.0}"
# Fetch frequency in hours for gateways dashboard repository
fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}"
+ ssl:
+ # SSL/TLS settings for the transport layer
+ certificate:
+ # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.)
+ reload:
+ # Enable/disable automatic SSL certificates reload
+ enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}"
+ # Interval in seconds for certificate reload
+ check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}"
# CoAP server parameters
# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings.
diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
index 7041a71086..9a001e0575 100644
--- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
+++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
@@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
-import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;
@@ -401,6 +401,57 @@ public class AlarmRulesTest extends AbstractControllerTest {
});
}
+ @Test
+ public void testChangeDurationConditionFromStaticToDynamic() throws Exception {
+ Argument temperatureArgument = new Argument();
+ temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
+ temperatureArgument.setDefaultValue("0");
+ Map arguments = Map.of(
+ "temperature", temperatureArgument
+ );
+
+ long staticDurationMs = 5000L;
+ Map createRules = Map.of(
+ AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, staticDurationMs)
+ );
+
+ AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm",
+ arguments, createRules, null);
+ CalculatedFieldId alarmRuleId = alarmRule.getId();
+
+ // post telemetry to trigger condition and wait for the static-phase eval to produce a debug event,
+ // which guarantees firstEventTs > 0 in AlarmRuleState before we trigger REINIT
+ postTelemetry(deviceId, "{\"temperature\":50}");
+ await().atMost(TIMEOUT, TimeUnit.SECONDS)
+ .until(() -> getDebugEvents(alarmRuleId, 1),
+ events -> !events.isEmpty() && !events.get(0).getId().equals(latestEventId));
+
+ // update CF: add attribute argument and switch duration from static to dynamic
+ AlarmCalculatedFieldConfiguration configuration = alarmRule.getConfiguration();
+
+ Argument durationArgument = new Argument();
+ durationArgument.setRefEntityKey(new ReferencedEntityKey("durationThreshold",
+ ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
+ durationArgument.setDefaultValue("-1");
+ configuration.getArguments().put("durationThreshold", durationArgument);
+
+ DurationAlarmCondition durationCondition = (DurationAlarmCondition)
+ configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition();
+ durationCondition.setValue(new AlarmConditionValue<>(null, "durationThreshold"));
+
+ alarmRule = saveAlarmRule(alarmRule);
+
+ long dynamicDurationMs = 3000L;
+ postAttributes(deviceId, AttributeScope.SERVER_SCOPE,
+ "{\"durationThreshold\":" + dynamicDurationMs + "}");
+
+ checkAlarmResult(alarmRule, alarmResult -> {
+ assertThat(alarmResult.isCreated()).isTrue();
+ assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
+ assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
+ });
+ }
+
@Test
public void testCreateAlarm_currentOwnerArgument() throws Exception {
Argument temperatureArgument = new Argument();
diff --git a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
index d17dd9d0f6..7594485c14 100644
--- a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
@@ -17,10 +17,8 @@ package org.thingsboard.server.client;
import org.junit.Test;
import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration;
-import org.thingsboard.client.model.AlarmConditionValueAlarmRuleSchedule;
-import org.thingsboard.client.model.AlarmRuleDefinition;
-import org.thingsboard.client.model.AlarmRuleSimpleCondition;
-import org.thingsboard.client.model.AlarmRuleSpecificTimeSchedule;
+import org.thingsboard.client.model.AlarmConditionValueAlarmSchedule;
+import org.thingsboard.client.model.AlarmRule;
import org.thingsboard.client.model.AlarmSeverity;
import org.thingsboard.client.model.Argument;
import org.thingsboard.client.model.ArgumentType;
@@ -30,7 +28,9 @@ import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.PageDataCalculatedField;
import org.thingsboard.client.model.ReferencedEntityKey;
+import org.thingsboard.client.model.SimpleAlarmCondition;
import org.thingsboard.client.model.SimpleCalculatedFieldConfiguration;
+import org.thingsboard.client.model.SpecificTimeSchedule;
import org.thingsboard.client.model.TbelAlarmConditionExpression;
import org.thingsboard.client.model.TimeSeriesOutput;
import org.thingsboard.server.dao.service.DaoSqlTest;
@@ -198,12 +198,12 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression)
TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression();
createExpression.setExpression("return temp > 50;");
- AlarmRuleSimpleCondition createCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition createCondition = new SimpleAlarmCondition();
createCondition.setExpression(createExpression);
- AlarmRuleSpecificTimeSchedule specificTimeSchedule = new AlarmRuleSpecificTimeSchedule().addDaysOfWeekItem(3);
- AlarmConditionValueAlarmRuleSchedule schedule = new AlarmConditionValueAlarmRuleSchedule().staticValue(specificTimeSchedule);
+ SpecificTimeSchedule specificTimeSchedule = new SpecificTimeSchedule().addDaysOfWeekItem(3);
+ AlarmConditionValueAlarmSchedule schedule = new AlarmConditionValueAlarmSchedule().staticValue(specificTimeSchedule);
createCondition.setSchedule(schedule);
- AlarmRuleDefinition createRule = new AlarmRuleDefinition();
+ AlarmRule createRule = new AlarmRule();
createRule.setCondition(createCondition);
createRule.setAlarmDetails("Temperature is too high: ${temp}");
config.setCreateRules(Map.of(
@@ -213,9 +213,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// clear rule: when temp drops below 30
TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression();
clearExpression.setExpression("return temp < 30;");
- AlarmRuleSimpleCondition clearCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition clearCondition = new SimpleAlarmCondition();
clearCondition.setExpression(clearExpression);
- AlarmRuleDefinition clearRule = new AlarmRuleDefinition();
+ AlarmRule clearRule = new AlarmRule();
clearRule.setCondition(clearCondition);
config.setClearRule(clearRule);
@@ -236,8 +236,8 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
assertEquals(cf.getName(), created.getName());
assertEquals(CalculatedFieldType.ALARM, created.getType());
AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration();
- AlarmConditionValueAlarmRuleSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule();
- AlarmRuleSpecificTimeSchedule staticSchedule = (AlarmRuleSpecificTimeSchedule) createdSchedule.getStaticValue();
+ AlarmConditionValueAlarmSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule();
+ SpecificTimeSchedule staticSchedule = (SpecificTimeSchedule) createdSchedule.getStaticValue();
assertEquals(Set.of(3), staticSchedule.getDaysOfWeek());
// get by id and verify configuration
@@ -257,9 +257,9 @@ public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
// update: add a second create rule for CRITICAL_TEMPERATURE
TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression();
criticalExpression.setExpression("return temp > 80;");
- AlarmRuleSimpleCondition criticalCondition = new AlarmRuleSimpleCondition();
+ SimpleAlarmCondition criticalCondition = new SimpleAlarmCondition();
criticalCondition.setExpression(criticalExpression);
- AlarmRuleDefinition criticalRule = new AlarmRuleDefinition();
+ AlarmRule criticalRule = new AlarmRule();
criticalRule.setCondition(criticalCondition);
fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule);
fetched.setConfiguration(fetchedConfig);
diff --git a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
index bd10a3deca..e9498a1c50 100644
--- a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
@@ -69,7 +69,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
}
// list tenant domains with text search
- PageDataDomainInfo filteredDomains = client.getTenantDomainInfos(100, 0,
+ PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0,
"domain.", null, null);
assertNotNull(filteredDomains);
assertEquals(5, filteredDomains.getData().size());
@@ -97,7 +97,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
client.getDomainInfoById(domainToDeleteId)
);
- PageDataDomainInfo domainsAfterDelete = client.getTenantDomainInfos(100, 0,
+ PageDataDomainInfo domainsAfterDelete = client.getDomainInfos(100, 0,
"domain.", null, null);
assertEquals(4, domainsAfterDelete.getData().size());
}
diff --git a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
index 63f05710ae..2099f405f0 100644
--- a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
+++ b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
@@ -90,7 +90,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
}
// list tenant OAuth2 client infos
- PageDataOAuth2ClientInfo clientInfos = client.findTenantOAuth2ClientInfos(100, 0,
+ PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertNotNull(clientInfos);
assertEquals(5, clientInfos.getData().size());
@@ -130,7 +130,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
client.getOAuth2ClientById(clientToDeleteId)
);
- PageDataOAuth2ClientInfo clientsAfterDelete = client.findTenantOAuth2ClientInfos(100, 0,
+ PageDataOAuth2ClientInfo clientsAfterDelete = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertEquals(4, clientsAfterDelete.getData().size());
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
index 099ef05752..100f44d4b7 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
@@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions;
+import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
+import org.thingsboard.server.common.data.ai.dto.TbChatRequest;
+import org.thingsboard.server.common.data.ai.dto.TbChatResponse;
+import org.thingsboard.server.common.data.ai.dto.TbContent;
+import org.thingsboard.server.common.data.ai.dto.TbUserMessage;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
@@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
@@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest {
assertThat(updatedModel.getExternalId()).isNull();
}
+ @Test
+ public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception {
+ // GIVEN
+ loginTenantAdmin();
+ SsrfProtectionValidator.setEnabled(true);
+
+ try {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://172.17.0.1:22/")
+ .apiKey("test-api-key")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ AiModel model = AiModel.builder()
+ .tenantId(tenantId)
+ .name("SSRF test model")
+ .configuration(modelConfig)
+ .build();
+
+ // WHEN
+ ResultActions result = doPost("/api/ai/model", model);
+
+ // THEN
+ result.andExpect(status().isBadRequest());
+ } finally {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+ }
+
+ @Test
+ public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception {
+ // GIVEN
+ loginTenantAdmin();
+ SsrfProtectionValidator.setEnabled(true);
+
+ try {
+ var modelConfig = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://10.0.0.1:8080/")
+ .apiKey("test-api-key")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ var chatRequest = new TbChatRequest(
+ null,
+ new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))),
+ modelConfig);
+
+ // WHEN
+ TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk());
+
+ // THEN
+ assertThat(response).isInstanceOf(TbChatResponse.Failure.class);
+ assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid");
+ } finally {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+ }
+
/* --- Get by ID API tests --- */
@Test
diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
index 17a30cac35..3bb5fd5647 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
@@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -226,6 +228,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
.andExpect(status().isOk());
+ Optional systemCommentOpt = doGetTyped(
+ "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() {
+ }
+ ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
+ assertThat(systemCommentOpt).isPresent();
+ AlarmCommentInfo systemComment = systemCommentOpt.get();
+
+ assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
+ assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
+ assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment",
+ TENANT_ADMIN_EMAIL));
+
AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM)
@@ -361,6 +375,39 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
Assert.assertTrue("Created alarm doesn't match the found one!", equals);
}
+ @Test
+ public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception {
+ loginTenantAdmin();
+
+ AlarmComment alarmComment = AlarmComment.builder()
+ .type(AlarmCommentType.SYSTEM)
+ .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin")))
+ .build();
+ AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class);
+ assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER);
+
+ // acknowledge alarm to create system comment
+ doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk());
+
+ Optional systemCommentOpt = doGetTyped(
+ "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() {
+ }
+ ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst();
+ assertThat(systemCommentOpt).isPresent();
+ AlarmCommentInfo systemComment = systemCommentOpt.get();
+
+ // system comment can't be updated with other type
+ systemComment.setType(AlarmCommentType.OTHER);
+ doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("System alarm comment can't be updated!")));
+
+ // system comment can't be updated with other text
+ systemComment.setType(AlarmCommentType.SYSTEM);
+ systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment")));
+ doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("System alarm comment can't be updated!")));
+ }
+
private AlarmComment createAlarmComment(AlarmId alarmId, String text) {
AlarmComment alarmComment = AlarmComment.builder()
.comment(JacksonUtil.newObjectNode().set("text", new TextNode(text)))
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
index 3966bbb97a..0ee1a65cd8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
+import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -52,8 +53,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.device.DeviceProfileDao;
-import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.exception.DataValidationException;
import java.util.ArrayList;
import java.util.Arrays;
@@ -62,6 +63,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -93,6 +95,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) {
return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao));
}
+
}
@Before
@@ -640,364 +643,364 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " required int32 parameter = 1;\n" +
- "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " required int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " required int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " required int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
}
@Test
public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "option java_package = \"com.test.schemavalidation\";\n" +
- "option java_multiple_files = true;\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
+ "\n" +
+ "option java_package = \"com.test.schemavalidation\";\n" +
+ "option java_multiple_files = true;\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
}
@Test
public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "import public \"oldschema.proto\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
+ "\n" +
+ "import public \"oldschema.proto\";\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "import \"oldschema.proto\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SchemaValidationTest {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
+ "\n" +
+ "import \"oldschema.proto\";\n" +
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SchemaValidationTest {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "extend google.protobuf.MethodOptions {\n" +
- " MyMessage my_method_option = 50007;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "extend google.protobuf.MethodOptions {\n" +
+ " MyMessage my_method_option = 50007;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
}
@Test
public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "enum testEnum {\n" +
- " option allow_alias = true;\n" +
- " DEFAULT = 0;\n" +
- " STARTED = 1;\n" +
- " RUNNING = 2;\n" +
- "}\n" +
- "\n" +
- "message testMessage {\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "enum testEnum {\n" +
+ " option allow_alias = true;\n" +
+ " DEFAULT = 0;\n" +
+ " STARTED = 1;\n" +
+ " RUNNING = 2;\n" +
+ "}\n" +
+ "\n" +
+ "message testMessage {\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
}
@Test
public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "enum testEnum {\n" +
- " DEFAULT = 0;\n" +
- " STARTED = 1;\n" +
- " RUNNING = 2;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "enum testEnum {\n" +
+ " DEFAULT = 0;\n" +
+ " STARTED = 1;\n" +
+ " RUNNING = 2;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message testMessage {\n" +
- " option allow_alias = true;\n" +
- " optional int32 parameter = 1;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message testMessage {\n" +
+ " option allow_alias = true;\n" +
+ " optional int32 parameter = 1;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message TestMessage {\n" +
- " extensions 100 to 199;\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message TestMessage {\n" +
+ " extensions 100 to 199;\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message Foo {\n" +
- " reserved 2, 15, 9 to 11;\n" +
- " reserved \"foo\", \"bar\";\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message Foo {\n" +
+ " reserved 2, 15, 9 to 11;\n" +
+ " reserved \"foo\", \"bar\";\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message TestMessage {\n" +
- " repeated group Result = 1 {\n" +
- " optional string url = 2;\n" +
- " optional string title = 3;\n" +
- " repeated string snippets = 4;\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message TestMessage {\n" +
+ " repeated group Result = 1 {\n" +
+ " optional string url = 2;\n" +
+ " optional string title = 3;\n" +
+ " repeated string snippets = 4;\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message SampleMessage {\n" +
- " oneof test_oneof {\n" +
- " string name = 1;\n" +
- " group Result = 2 {\n" +
- " \tstring url = 3;\n" +
- " \tstring title = 4;\n" +
- " \trepeated string snippets = 5;\n" +
- " }\n" +
- " }" +
- "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message SampleMessage {\n" +
+ " oneof test_oneof {\n" +
+ " string name = 1;\n" +
+ " group Result = 2 {\n" +
+ " \tstring url = 3;\n" +
+ " \tstring title = 4;\n" +
+ " \trepeated string snippets = 5;\n" +
+ " }\n" +
+ " }" +
+ "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " int64 ts = 1;\n" +
- " Values values = 2;\n" +
- " \n" +
- " message Values {\n" +
- " string key1 = 3;\n" +
- " bool key2 = 4;\n" +
- " double key3 = 5;\n" +
- " int32 key4 = 6;\n" +
- " JsonObject key5 = 7;\n" +
- " }\n" +
- " \n" +
- " message JsonObject {\n" +
- " optional int32 someNumber = 8;\n" +
- " repeated int32 someArray = 9;\n" +
- " NestedJsonObject someNestedObject = 10;\n" +
- " message NestedJsonObject {\n" +
- " optional string key = 11;\n" +
- " }\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " int64 ts = 1;\n" +
+ " Values values = 2;\n" +
+ " \n" +
+ " message Values {\n" +
+ " string key1 = 3;\n" +
+ " bool key2 = 4;\n" +
+ " double key3 = 5;\n" +
+ " int32 key4 = 6;\n" +
+ " JsonObject key5 = 7;\n" +
+ " }\n" +
+ " \n" +
+ " message JsonObject {\n" +
+ " optional int32 someNumber = 8;\n" +
+ " repeated int32 someArray = 9;\n" +
+ " NestedJsonObject someNestedObject = 10;\n" +
+ " message NestedJsonObject {\n" +
+ " optional string key = 11;\n" +
+ " }\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " optional int32 ts = 1;\n" +
- " Values values = 2;\n" +
- " \n" +
- " message Values {\n" +
- " string key1 = 3;\n" +
- " bool key2 = 4;\n" +
- " double key3 = 5;\n" +
- " int32 key4 = 6;\n" +
- " JsonObject key5 = 7;\n" +
- " }\n" +
- " \n" +
- " message JsonObject {\n" +
- " optional int32 someNumber = 8;\n" +
- " }\n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " optional int32 ts = 1;\n" +
+ " Values values = 2;\n" +
+ " \n" +
+ " message Values {\n" +
+ " string key1 = 3;\n" +
+ " bool key2 = 4;\n" +
+ " double key3 = 5;\n" +
+ " int32 key4 = 6;\n" +
+ " JsonObject key5 = 7;\n" +
+ " }\n" +
+ " \n" +
+ " message JsonObject {\n" +
+ " optional int32 someNumber = 8;\n" +
+ " }\n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message PostTelemetry {\n" +
- " optional int64 ts = 1;\n" +
- " string values = 2;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message PostTelemetry {\n" +
+ " optional int64 ts = 1;\n" +
+ " string values = 2;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional int32 method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional int32 method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int64 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int64 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " repeated string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " repeated string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " repeated int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " repeated int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " repeated string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " repeated string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string methodName = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string methodName = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestIdentifier = 2;\n" +
- " optional string params = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestIdentifier = 2;\n" +
+ " optional string params = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
- "\n" +
- "package schemavalidation;\n" +
- "\n" +
- "message RpcRequestMsg {\n" +
- " optional string method = 1;\n" +
- " optional int32 requestId = 2;\n" +
- " optional string parameters = 3;\n" +
- " \n" +
- "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
+ "\n" +
+ "package schemavalidation;\n" +
+ "\n" +
+ "message RpcRequestMsg {\n" +
+ " optional string method = 1;\n" +
+ " optional int32 requestId = 2;\n" +
+ " optional string parameters = 3;\n" +
+ " \n" +
+ "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
}
@Test
@@ -1068,11 +1071,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
- Mockito.reset(tbClusterService, auditLogService);
-
- doPost("/api/deviceProfile", deviceProfile)
- .andExpect(status().isBadRequest())
- .andExpect(statusReason(containsString(errorMsg)));
+ // The request may hit a transient TenantNotFoundException right after the @Before tenant creation
+ // if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the
+ // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the
+ // retry loop so the subsequent verify* assertions see only the invocations from the last attempt.
+ Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
+ .ignoreExceptions().untilAsserted(() -> {
+ Mockito.reset(tbClusterService, auditLogService);
+ doPost("/api/deviceProfile", deviceProfile)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString(errorMsg)));
+ });
testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(),
tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg));
diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
index 835f00985b..5de4c9ffcf 100644
--- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
@@ -28,6 +28,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
@@ -62,6 +63,8 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AliasEntityId;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
+import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType;
@@ -89,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
+import org.thingsboard.server.dao.entity.BaseEntityService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb;
@@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired
private QueueStatsService queueStatsService;
+ @Autowired
+ private BaseEntityService baseEntityService;
@MockitoBean
private EdqsRocksDb edqsRocksDb;
@@ -1343,7 +1349,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard
doPost("/api/customer/" + savedCustomer.getId().getId().toString()
- + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
+ + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
// check entity data query by customer
User customerUser = new User();
@@ -1729,4 +1735,523 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
}
+ // --- OR conditions tests ---
+
+ private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception {
+ Device device = new Device();
+ device.setName(name);
+ device.setType(type);
+ device = doPost("/api/device", device, Device.class);
+ if (sharedAttributesPayload != null) {
+ doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
+ sharedAttributesPayload, String.class, status().isOk());
+ }
+ return device;
+ }
+
+ private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception {
+ Device device = new Device();
+ device.setName(name);
+ device.setType(type);
+ device = doPost("/api/device", device, Device.class);
+ JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload);
+ doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload)
+ .andExpect(status().isOk());
+ return device;
+ }
+
+ private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception {
+ Alarm alarm = new Alarm();
+ alarm.setOriginator(originator);
+ alarm.setType(type);
+ alarm.setSeverity(severity);
+ return doPost("/api/alarm", alarm, Alarm.class);
+ }
+
+ private static DeviceTypeFilter deviceTypeFilter(String type) {
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of(type));
+ filter.setDeviceNameFilter("");
+ return filter;
+ }
+
+ private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key,
+ NumericFilterPredicate.NumericOperation operation, double value) {
+ KeyFilter keyFilter = new KeyFilter();
+ keyFilter.setKey(new EntityKey(keyType, key));
+ keyFilter.setValueType(EntityKeyValueType.NUMERIC);
+ NumericFilterPredicate predicate = new NumericFilterPredicate();
+ predicate.setValue(FilterPredicateValue.fromDouble(value));
+ predicate.setOperation(operation);
+ keyFilter.setPredicate(predicate);
+ return keyFilter;
+ }
+
+ private static KeyFilter numericAttributeKeyFilter(String key,
+ NumericFilterPredicate.NumericOperation operation, double value) {
+ return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
+ }
+
+ private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key,
+ StringFilterPredicate.StringOperation operation, String value) {
+ KeyFilter keyFilter = new KeyFilter();
+ keyFilter.setKey(new EntityKey(keyType, key));
+ keyFilter.setValueType(EntityKeyValueType.STRING);
+ StringFilterPredicate predicate = new StringFilterPredicate();
+ predicate.setValue(FilterPredicateValue.fromString(value));
+ predicate.setOperation(operation);
+ keyFilter.setPredicate(predicate);
+ return keyFilter;
+ }
+
+ private static KeyFilter stringAttributeKeyFilter(String key,
+ StringFilterPredicate.StringOperation operation, String value) {
+ return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
+ }
+
+ private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) {
+ EntityDataSortOrder sortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC);
+ return new EntityDataPageLink(pageSize, page, textSearch, sortOrder);
+ }
+
+ private static List nameEntityField() {
+ return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
+ }
+
+ private static List extractNames(PageData result) {
+ return result.getData().stream()
+ .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
+ .collect(Collectors.toList());
+ }
+
+ @Test
+ public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
+ String type = "orTestType";
+ createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}");
+ createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR: deviceA (60>50) and deviceB (5<10) match => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
+
+ // AND: no device has temperature both >50 AND <10 => count=0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception {
+ String type = "orDataType";
+ createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}");
+ createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}");
+ createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute
+
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70));
+
+ // OR: deviceX matches status=active, deviceY matches humidity>70
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
+ PageData result = findByQueryAndCheck(orQuery, 2);
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
+ }
+
+ @Test
+ public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception {
+ // Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in
+ // entityFields must not leak its value into EntityData.latest[ENTITY_FIELD].
+ String type = "orLeakGuardType";
+ Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}");
+ d1.setLabel("leak-label-A");
+ doPost("/api/device", d1, Device.class);
+
+ Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null);
+ d2.setLabel("leak-label-B");
+ doPost("/api/device", d2, Device.class);
+
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
+ buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B"));
+
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
+ PageData result = findByQueryAndCheck(orQuery, 2);
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB");
+ for (EntityData entity : result.getData()) {
+ assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name");
+ }
+ }
+
+ @Test
+ public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
+ String type = "orSameKeyType";
+ createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}");
+
+ // Same key "temperature" with two different predicates exercises the OR-ungrouping path
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
+ PageData result = findByQueryAndCheck(orQuery, 2);
+ assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
+ }
+
+ @Test
+ public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception {
+ String type = "backCompatType";
+ createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // Await attribute propagation via an OR query that should find 2 when propagated
+ EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2));
+
+ // Query without keyFiltersOperation (null) -- should behave as AND
+ EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters);
+ Long nullResult = countByQueryAndCheck(nullOpQuery, 0);
+
+ // Query with explicit AND -- should produce the same result
+ EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ Long andResult = countByQueryAndCheck(andOpQuery, 0);
+
+ Assert.assertEquals(nullResult, andResult);
+ }
+
+ @Test
+ public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception {
+ loginTenantAdmin();
+
+ String type = "orAlarmType";
+ Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}");
+ Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}");
+ Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}");
+
+ createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL);
+ createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
+ createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ AlarmDataPageLink pageLink = new AlarmDataPageLink();
+ pageLink.setPage(0);
+ pageLink.setPageSize(100);
+ pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime")));
+ List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
+
+ // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms
+ AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2));
+ PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
+ List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList());
+ assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp");
+
+ // AND query: no device has temp both >50 AND <10 => 0 alarms
+ AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
+ findAlarmsByQueryAndCheck(andAlarmQuery, 0);
+ }
+
+ @Test
+ public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception {
+ loginTenantAdmin();
+
+ String type = "orAlarmCntType";
+ Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}");
+ Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}");
+ Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}");
+
+ // 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
+ createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL);
+ createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL);
+ createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
+ createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total
+ AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3));
+
+ // AND: no device matches both filters => 0
+ AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countAlarmsByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception {
+ // Exercises the entity field predicate relocation to middle-layer WHERE under OR
+ String type = "orMixedType";
+ createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}");
+ createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}");
+
+ List keyFilters = List.of(
+ stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
+
+ // OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
+
+ // AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrStringAttributes() throws Exception {
+ String type = "orStrType";
+ createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}");
+ createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}");
+ createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}");
+
+ List keyFilters = List.of(
+ stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"),
+ stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue"));
+
+ // OR: red and blue match => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
+
+ // AND: no device is both red AND blue => count=0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrSingleFilter() throws Exception {
+ String type = "orSingleType";
+ createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
+
+ // Single filter with OR should behave identically to AND
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1));
+ Long orResult = countByQueryAndCheck(orQuery, 1);
+
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ Long andResult = countByQueryAndCheck(andQuery, 1);
+
+ Assert.assertEquals(orResult, andResult);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrThreeFilters() throws Exception {
+ String type = "or3fType";
+ createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}");
+ createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}");
+ createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}");
+ createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80),
+ numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040));
+
+ // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3));
+
+ // AND: no device matches all three => 0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testFindEntityDataWithOrPagination() throws Exception {
+ // 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read.
+ String type = "orPageType";
+ int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match
+ for (int i = 0; i < temperatures.length; i++) {
+ createDeviceWithSharedAttributes(
+ String.format("OrPageDevice%02d", i + 1), type,
+ "{\"temperature\":" + temperatures[i] + "}");
+ }
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // Page 1: pageSize=2, totalElements=4, data.size()=2
+ EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4));
+ PageData page1 = findByQueryAndCheck(orQuery1, 4);
+ Assert.assertEquals(2, page1.getData().size());
+ Assert.assertTrue(page1.hasNext());
+
+ // Page 2: remaining 2 of 4
+ EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ PageData page2 = findByQueryAndCheck(orQuery2, 4);
+ Assert.assertEquals(2, page2.getData().size());
+ Assert.assertFalse(page2.hasNext());
+
+ // All names across both pages should be from the 4 matching devices (01..04)
+ List allNames = new ArrayList<>(extractNames(page1));
+ allNames.addAll(extractNames(page2));
+ assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05");
+ }
+
+ @Test
+ public void testCountEntitiesWithOrZeroMatches() throws Exception {
+ String type = "orZeroType";
+ createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}");
+ createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}");
+
+ // Await attribute propagation via a filter that actually matches both devices
+ EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)),
+ ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2));
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR with no matches: neither filter matches any device => count=0
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ countByQueryAndCheck(orQuery, 0);
+ }
+
+ @Test
+ public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception {
+ loginTenantAdmin();
+
+ ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false);
+ try {
+ DeviceTypeFilter filter = deviceTypeFilter("default");
+
+ // POST a query with OR operation -- should be rejected with 400
+ EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR);
+ String errorMessage = getErrorMessage(
+ doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest())
+ );
+ assertThat(errorMessage).contains("OR conditions between key filters are disabled");
+
+ // POST a query without keyFiltersOperation (null/AND) -- should still succeed
+ EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList());
+ doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk());
+
+ // POST a query with explicit AND -- should also succeed
+ EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND);
+ doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk());
+ } finally {
+ ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true);
+ }
+ }
+
+ @Test
+ public void testFindEntityDataWithOrAndTextSearch() throws Exception {
+ // 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time
+ String type = "orTextType";
+ createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}");
+ createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}");
+ createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}");
+
+ List keyFilters = List.of(
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR without textSearch: Alpha and Beta match => 2
+ EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
+
+ // OR with textSearch="Alpha": only Alpha matches both OR filter AND text search
+ EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha");
+
+ // OR with textSearch="Beta": only Beta matches both OR filter AND text search
+ EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta");
+
+ // OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0
+ EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"),
+ nameEntityField(), null, keyFilters, ComplexOperation.OR);
+ findByQueryAndCheck(orQueryGamma, 0);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception {
+ String type = "orTsType";
+ createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}");
+ createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}");
+ createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}");
+
+ List keyFilters = List.of(
+ numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
+ numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
+
+ // OR: deviceA (60>50) and deviceB (5<10) match => count=2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
+
+ // AND: no device has ts temperature both >50 AND <10 => count=0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
+ @Test
+ public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception {
+ // Key-level ComplexFilterPredicate combined with query-level OR
+ String type = "orCplxType";
+ createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}");
+ createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}");
+ createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}");
+
+ // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only
+ NumericFilterPredicate gt50 = new NumericFilterPredicate();
+ gt50.setValue(FilterPredicateValue.fromDouble(50));
+ gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
+ NumericFilterPredicate lt70 = new NumericFilterPredicate();
+ lt70.setValue(FilterPredicateValue.fromDouble(70));
+ lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS);
+ ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate();
+ complexTempPred.setOperation(ComplexOperation.AND);
+ complexTempPred.setPredicates(List.of(gt50, lt70));
+ KeyFilter tempComplexFilter = new KeyFilter();
+ tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
+ tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC);
+ tempComplexFilter.setPredicate(complexTempPred);
+
+ // Key filter 2: humidity > 80 (simple predicate) — matches B only
+ List keyFilters = List.of(
+ tempComplexFilter,
+ numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80));
+
+ // Query-level OR: A matches key filter 1, B matches key filter 2 => 2
+ EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
+ await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
+
+ // Query-level AND: no device matches both key filters => 0
+ EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
+ countByQueryAndCheck(andQuery, 0);
+ }
+
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
index accbbd1d29..970c244fad 100644
--- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
@@ -27,12 +27,19 @@ import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.adapter.NativeWebSocketSession;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider;
import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider;
import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
import org.thingsboard.server.service.ws.WebSocketService;
import org.thingsboard.server.service.ws.WebSocketSessionRef;
import org.thingsboard.server.service.ws.WebSocketSessionType;
@@ -193,6 +200,72 @@ class TbWebSocketHandlerTest {
assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100);
}
+ // Regression test for the bug where publicUserSessionsMap was keyed by UserId(NULL_UUID),
+ // making maxWsSessionsPerPublicUser a global limit shared across all tenants.
+ // The limit is now scoped per-tenant.
+ @Test
+ void checkLimits_publicUserSessions_limitIsPerTenantNotGlobal() throws Exception {
+ TbTenantProfileCache tenantProfileCache = mock(TbTenantProfileCache.class);
+ ReflectionTestUtils.setField(wsHandler, "tenantProfileCache", tenantProfileCache);
+
+ int maxPublicSessions = 2;
+
+ TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile1 = new TenantProfile();
+ profile1.createDefaultTenantProfileData();
+ profile1.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions);
+ willReturn(profile1).given(tenantProfileCache).get(tenant1);
+
+ TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile2 = new TenantProfile();
+ profile2.createDefaultTenantProfileData();
+ profile2.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions);
+ willReturn(profile2).given(tenantProfileCache).get(tenant2);
+
+ Method checkLimits = TbWebSocketHandler.class.getDeclaredMethod(
+ "checkLimits", WebSocketSession.class, WebSocketSessionRef.class);
+ checkLimits.setAccessible(true);
+
+ // tenant1 fills up its limit
+ for (int i = 0; i < maxPublicSessions; i++) {
+ assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t1-" + i), mockPublicSessionRef(tenant1))).isTrue();
+ }
+
+ // tenant2 must get its own independent quota — this was the bug: with NULL_UUID as key
+ // all tenants shared one global counter, so tenant2 would be blocked here
+ for (int i = 0; i < maxPublicSessions; i++) {
+ assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t2-" + i), mockPublicSessionRef(tenant2)))
+ .as("tenant2 session %d should not be affected by tenant1's sessions", i + 1)
+ .isTrue();
+ }
+
+ // tenant1's (maxPublicSessions + 1)-th session must be rejected
+ NativeWebSocketSession overLimit = mockWsSession("t1-over");
+ assertThat((boolean) checkLimits.invoke(wsHandler, overLimit, mockPublicSessionRef(tenant1))).isFalse();
+ verify(overLimit).close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached"));
+ }
+
+ private NativeWebSocketSession mockWsSession(String id) {
+ NativeWebSocketSession s = mock(NativeWebSocketSession.class);
+ willReturn(id).given(s).getId();
+ return s;
+ }
+
+ private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId) {
+ CustomerId customerId = new CustomerId(UUID.randomUUID());
+ SecurityUser securityUser = mock(SecurityUser.class);
+ willReturn(tenantId).given(securityUser).getTenantId();
+ willReturn(customerId).given(securityUser).getCustomerId();
+ willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId();
+ willReturn(true).given(securityUser).isCustomerUser();
+ willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal();
+
+ WebSocketSessionRef ref = mock(WebSocketSessionRef.class);
+ willReturn(securityUser).given(ref).getSecurityCtx();
+ willReturn(UUID.randomUUID().toString()).given(ref).getSessionId();
+ return ref;
+ }
+
private AuthTestFixture createAuthTestFixture() throws IOException {
TbWebSocketHandler handler = spy(new TbWebSocketHandler());
willDoNothing().given(handler).close(any(), any());
diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
index f344335748..91dd0669b1 100644
--- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
+++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
@@ -20,7 +20,6 @@ import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
-import org.springframework.test.web.servlet.ResultMatcher;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
@@ -224,7 +223,12 @@ public class UserEdgeTest extends AbstractEdgeTest {
User savedUser = createUser(user, password);
Assert.assertTrue(edgeImitator.waitForMessages());
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
- Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size());
+ // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when
+ // user activation completes before the event is processed, in addition to the 2
+ // messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3.
+ int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size();
+ Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount,
+ credMsgCount == 2 || credMsgCount == 3);
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);
diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
new file mode 100644
index 0000000000..fb9807a2a8
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.service.ai;
+
+import com.google.cloud.vertexai.api.GenerationConfig;
+import dev.langchain4j.model.chat.ChatModel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.common.util.SsrfProtectionValidator;
+import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
+import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
+import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
+import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@ResourceLock("SsrfProtectionValidator")
+class Langchain4jChatModelConfigurerImplTest {
+
+ private static final String TEST_SERVICE_ACCOUNT_KEY = """
+ {
+ "type": "service_account",
+ "project_id": "test-project",
+ "private_key_id": "key-id",
+ "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n",
+ "client_email": "test@test-project.iam.gserviceaccount.com",
+ "client_id": "123456789",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token"
+ }
+ """;
+
+ private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
+
+ @BeforeEach
+ void enableSsrfProtection() {
+ SsrfProtectionValidator.setEnabled(true);
+ }
+
+ @AfterEach
+ void disableSsrfProtection() {
+ SsrfProtectionValidator.setEnabled(false);
+ }
+
+ @Test
+ void configureChatModel_openAi_withPrivateIp_shouldThrow() {
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://172.17.0.1:8080/")
+ .apiKey("test")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
+ var config = OpenAiChatModelConfig.builder()
+ .providerConfig(OpenAiProviderConfig.builder()
+ .baseUrl("http://localhost:22/")
+ .apiKey("test")
+ .build())
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
+ var config = AzureOpenAiChatModelConfig.builder()
+ .providerConfig(new AzureOpenAiProviderConfig(
+ "http://10.0.0.1:8080/", null, "test-key"))
+ .modelId("gpt-4o")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_ollama_withPrivateIp_shouldThrow() {
+ var config = OllamaChatModelConfig.builder()
+ .providerConfig(new OllamaProviderConfig(
+ "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
+ .modelId("llama3")
+ .build();
+
+ assertThatThrownBy(() -> configurer.configureChatModel(config))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("URI is invalid");
+ }
+
+ @Test
+ void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
+ // GIVEN
+ var providerConfig = new GoogleVertexAiGeminiProviderConfig(
+ "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
+ );
+ var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
+ .providerConfig(providerConfig)
+ .modelId("gemini-2.0-flash")
+ .frequencyPenalty(0.3)
+ .presencePenalty(0.7)
+ .build();
+
+ // WHEN
+ ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
+
+ // THEN
+ var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
+ assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
+ assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
new file mode 100644
index 0000000000..3ee229e7a0
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
@@ -0,0 +1,517 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.service.cf;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.cf.CalculatedField;
+import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.CalculatedFieldId;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.TenantProfileId;
+import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.asset.AssetService;
+import org.thingsboard.server.dao.cf.CalculatedFieldService;
+import org.thingsboard.server.dao.customer.CustomerService;
+import org.thingsboard.server.dao.device.DeviceService;
+import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
+import org.thingsboard.server.service.profile.TbAssetProfileCache;
+import org.thingsboard.server.service.profile.TbDeviceProfileCache;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultCalculatedFieldCacheTest {
+
+ @Mock
+ private CalculatedFieldService calculatedFieldService;
+ @Mock
+ private TbAssetProfileCache assetProfileCache;
+ @Mock
+ private TbDeviceProfileCache deviceProfileCache;
+ @Mock
+ private TbTenantProfileCache tenantProfileCache;
+ @Mock
+ private DeviceService deviceService;
+ @Mock
+ private AssetService assetService;
+ @Mock
+ private CustomerService customerService;
+
+ private DefaultCalculatedFieldCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ // ActorSystemContext is only used in getCalculatedFieldCtx (not tested here), so null is safe
+ OwnerService ownerService = new OwnerService(deviceService, assetService, customerService);
+ cache = new DefaultCalculatedFieldCache(calculatedFieldService, assetProfileCache,
+ deviceProfileCache, tenantProfileCache, null, ownerService);
+
+ }
+
+ // --- Tenant deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAllTenantCfsFromAllMaps() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceId device1 = new DeviceId(UUID.randomUUID());
+ DeviceId device2 = new DeviceId(UUID.randomUUID());
+
+ CalculatedField cf1 = addCfToCache(tenant1, device1);
+ CalculatedField cf2 = addCfToCache(tenant2, device2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(device2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, tenant);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After eviction, getDynamicEntities triggers a fresh load from ownerService (empty)
+ assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(device);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesLinksToLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId cfEntity = new DeviceId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+
+ CalculatedField cf = addCfToCache(tenant, cfEntity, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ }
+
+ // --- Device/Asset deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_evictsCfsForThatDevice() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+ addCfToCache(tenant, device, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceDeleted_evictsDeviceFromOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetDeleted_evictsCfsForThatAsset() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetId asset = new AssetId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, asset);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, asset, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceCreated_addsDeviceToOwnerEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.CREATED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+ }
+
+ // --- Customer deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_customerDeleted_evictsCustomerOwnerEntries() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ stubDeviceOwner(tenant, device, customer);
+
+ cache.addOwnerEntity(tenant, device);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED));
+
+ // The customer's owned-entities entry is evicted; fresh load returns empty
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ // --- DeviceProfile/AssetProfile deletion tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ DeviceId linkedDevice = new DeviceId(UUID.randomUUID());
+ addCfToCache(tenant, profileId, linkedDevice);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf1 = addCfToCache(tenant, profile1);
+ CalculatedField cf2 = addCfToCache(tenant, profile2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ AssetId linkedAsset = new AssetId(UUID.randomUUID());
+ addCfToCache(tenant, profileId, linkedAsset);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf1 = addCfToCache(tenant, profile1);
+ CalculatedField cf2 = addCfToCache(tenant, profile2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf1.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty();
+ assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2);
+ assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf);
+ }
+
+ // --- CalculatedField lifecycle tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldCreated_addsCfToCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedField cf = buildCalculatedField(cfId, tenant, device, simpleCfConfig());
+ when(calculatedFieldService.findById(tenant, cfId)).thenReturn(cf);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cfId, ComponentLifecycleEvent.CREATED));
+
+ assertThat(cache.getCalculatedField(cfId)).isEqualTo(cf);
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).containsExactly(cf);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldDeleted_evictsCfFromCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isNull();
+ assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_calculatedFieldUpdated_refreshesCfInCache() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+ CalculatedField cf = addCfToCache(tenant, device);
+
+ CalculatedField updatedCf = buildCalculatedField(cf.getId(), tenant, device, simpleCfConfig());
+ updatedCf.setName("updated-name");
+ when(calculatedFieldService.findById(tenant, cf.getId())).thenReturn(updatedCf);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.UPDATED));
+
+ assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf);
+ }
+
+ // --- evictOwner recursive traversal tests ---
+
+ @Test
+ public void evictOwner_customerDeleted_recursivelyEvictsDevicesOwnedByThatCustomer() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+
+ stubDeviceOwner(tenant, device, customer);
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+
+ // tenant owns customer (getOwner for CUSTOMER returns tenantId)
+ cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
+ cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
+
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ // deleting the customer evicts the customer key and recursively cleans its owned set
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ @Test
+ public void evictOwner_tenantDeleted_recursivelyEvictsCustomerAndItsOwnedDevices() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ CustomerId customer = new CustomerId(UUID.randomUUID());
+ DeviceId device = new DeviceId(UUID.randomUUID());
+
+ stubDeviceOwner(tenant, device, customer);
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+
+ cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
+ cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
+
+ assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).contains(device);
+
+ // deleting the tenant: evictOwner(tenant) finds customer (CUSTOMER type) and recurses into it
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // both levels must be gone
+ assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(customer);
+ assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device);
+ }
+
+ // --- TenantProfile lifecycle tests ---
+
+ @Test
+ public void onComponentLifecycleEvent_tenantProfileUpdated_callsHandleTenantProfileUpdate() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ TenantProfileId profileId = new TenantProfileId(UUID.randomUUID());
+ DefaultCalculatedFieldCache spyCache = spy(cache);
+
+ spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED));
+
+ verify(spyCache).handleTenantProfileUpdate(profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantProfileDeleted_doesNotCallHandleTenantProfileUpdate() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ TenantProfileId profileId = new TenantProfileId(UUID.randomUUID());
+ DefaultCalculatedFieldCache spyCache = spy(cache);
+
+ spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED));
+
+ verify(spyCache, never()).handleTenantProfileUpdate(any());
+ }
+
+ // --- Helpers ---
+
+ private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) {
+ Device device = new Device();
+ device.setId(deviceId);
+ device.setTenantId(tenantId);
+ if (ownerId instanceof CustomerId customerId) {
+ device.setCustomerId(customerId);
+ }
+ // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId
+ when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device);
+ // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly)
+ when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData());
+ when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData());
+ if (ownerId instanceof TenantId) {
+ when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData());
+ }
+ }
+
+ private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) {
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig());
+ when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
+ cache.addCalculatedField(tenantId, cfId);
+ return cf;
+ }
+
+ private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId, EntityId linkedEntity) {
+ CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID());
+ CalculatedFieldConfiguration config = linkedEntityCfConfig(tenantId, cfId, linkedEntity);
+ CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, config);
+ when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf);
+ cache.addCalculatedField(tenantId, cfId);
+ return cf;
+ }
+
+ private CalculatedField buildCalculatedField(CalculatedFieldId id, TenantId tenantId, EntityId entityId, CalculatedFieldConfiguration config) {
+ CalculatedField cf = new CalculatedField();
+ cf.setId(id);
+ cf.setTenantId(tenantId);
+ cf.setEntityId(entityId);
+ cf.setType(CalculatedFieldType.SIMPLE);
+ cf.setName("test-cf-" + id.getId());
+ cf.setConfiguration(config);
+ return cf;
+ }
+
+ private CalculatedFieldConfiguration simpleCfConfig() {
+ CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
+ when(config.getReferencedEntities()).thenReturn(Collections.emptySet());
+ when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(Collections.emptyList());
+ return config;
+ }
+
+ private CalculatedFieldConfiguration linkedEntityCfConfig(TenantId tenantId, CalculatedFieldId cfId, EntityId linkedEntity) {
+ CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class);
+ CalculatedFieldLink link = new CalculatedFieldLink(tenantId, linkedEntity, cfId);
+ when(config.getReferencedEntities()).thenReturn(Set.of(linkedEntity));
+ when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link));
+ when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link);
+ return config;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
index adf664e7cf..eb672a790f 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
@@ -16,19 +16,27 @@
package org.thingsboard.server.service.entitiy;
import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.IdBased;
+import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.page.PageData;
+import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData;
+import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
+import org.thingsboard.server.common.data.query.EntityDataSortOrder;
+import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@@ -43,10 +51,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
+import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
@DaoSqlTest
@TestPropertySource(properties = {
@@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest {
assetService.deleteAssetsByTenantId(tenantId);
}
+ // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
+ @Override
+ @Test
+ public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(1L, 0L, 0L);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1"));
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", ""));
+ }
+
+ // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
+ @Override
+ @Test
+ public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(true, false, false);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true"));
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, devices.size()).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", ""));
+ }
+
@Override
protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) {
return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query),
diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
index 05ab1165f6..b3230b3d45 100644
--- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
@@ -26,6 +26,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.ResultSetExtractor;
+import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
@@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
@@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation;
-import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
@@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao;
import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.service.DaoSqlTest;
+import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository;
import org.thingsboard.server.dao.sql.relation.RelationRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
@@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
-import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE;
@Slf4j
@DaoSqlTest
@@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest {
@Autowired
RelationRepository relationRepository;
@Autowired
+ DefaultEntityQueryRepository entityQueryRepository;
+ @Autowired
RelationService relationService;
@Autowired
TimeseriesService timeseriesService;
@@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
+ @Test
+ public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ try {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(1L, 0L, 0L);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ assertNullsOrdering("default",
+ List.of("0", "0", "1", "", ""),
+ List.of("", "", "1", "0", "0"),
+ devices.size());
+
+ assertNullsOrdering("nulls_first",
+ List.of("", "", "0", "0", "1"),
+ List.of("", "", "1", "0", "0"),
+ devices.size());
+
+ assertNullsOrdering("nulls_last",
+ List.of("0", "0", "1", "", ""),
+ List.of("1", "0", "0", "", ""),
+ devices.size());
+ } finally {
+ deviceService.deleteDevicesByTenantId(tenantId);
+ }
+ }
+
+ @Test
+ public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
+ try {
+ List devices = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ Device device = new Device();
+ device.setTenantId(tenantId);
+ device.setName("Device" + i);
+ device.setType("default");
+ devices.add(deviceService.saveDevice(device));
+ Thread.sleep(1);
+ }
+
+ List values = List.of(true, false, false);
+ List> timeseriesFutures = new ArrayList<>();
+ for (int i = 0; i < values.size(); i++) {
+ timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
+ }
+ Futures.allAsList(timeseriesFutures).get();
+
+ assertNullsOrdering("default",
+ List.of("false", "false", "true", "", ""),
+ List.of("", "", "true", "false", "false"),
+ devices.size());
+
+ assertNullsOrdering("nulls_first",
+ List.of("", "", "false", "false", "true"),
+ List.of("", "", "true", "false", "false"),
+ devices.size());
+
+ assertNullsOrdering("nulls_last",
+ List.of("false", "false", "true", "", ""),
+ List.of("true", "false", "false", "", ""),
+ devices.size());
+ } finally {
+ deviceService.deleteDevicesByTenantId(tenantId);
+ }
+ }
+
+ private void assertNullsOrdering(String strategy, List expectedAsc, List expectedDesc, int deviceSize) {
+ String originalStrategy = entityQueryRepository.getNullsOrderStrategy();
+ ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy);
+ try {
+ DeviceTypeFilter filter = new DeviceTypeFilter();
+ filter.setDeviceTypes(List.of("default"));
+ filter.setDeviceNameFilter("");
+
+ List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
+ List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
+
+ EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
+ EntityDataQuery ascQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
+ List ascTelemetry = loadAllData(ascQuery, deviceSize).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc);
+
+ EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
+ new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
+ EntityDataQuery descQuery = new EntityDataQuery(filter,
+ new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
+ List descTelemetry = loadAllData(descQuery, deviceSize).stream()
+ .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
+ .toList();
+ assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc);
+ } finally {
+ ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy);
+ }
+ }
+
@Test
public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin
@@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest {
return timeseriesService.save(tenantId, entityId, timeseries);
}
- private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) {
+ protected ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) {
KvEntry telemetryValue = new LongDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries);
}
+ protected ListenableFuture saveTimeseries(EntityId entityId, String key, Boolean value) {
+ KvEntry telemetryValue = new BooleanDataEntry(key, value);
+ BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
+ return timeseriesService.save(tenantId, entityId, timeseries);
+ }
+
protected void createMultiRootHierarchy(List buildings, List apartments,
Map> entityNameByTypeMap,
Map childParentRelationMap) throws InterruptedException {
diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
new file mode 100644
index 0000000000..f9b8d428d7
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
@@ -0,0 +1,159 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.service.profile;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.asset.Asset;
+import org.thingsboard.server.common.data.asset.AssetProfile;
+import org.thingsboard.server.common.data.id.AssetId;
+import org.thingsboard.server.common.data.id.AssetProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.asset.AssetProfileService;
+import org.thingsboard.server.dao.asset.AssetService;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbAssetProfileCacheTest {
+
+ @Mock
+ private AssetProfileService assetProfileService;
+ @Mock
+ private AssetService assetService;
+
+ private DefaultTbAssetProfileCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ cache = new DefaultTbAssetProfileCache(assetProfileService, assetService);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ // After deletion tenant1 profile should be reloaded from service on next get
+ when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant1, profileId1)).isNull();
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ AssetId assetId = new AssetId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant, profileId);
+ loadAssetMappingIntoCache(tenant, assetId, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload
+ when(assetService.findAssetById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant, assetId)).isNull();
+ verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ EntityId listenerId = new AssetId(UUID.randomUUID());
+ AtomicInteger callCount = new AtomicInteger();
+
+ cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // Evicting a profile after tenant deletion should not trigger the removed listener
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+ cache.evict(tenant, profileId);
+
+ assertThat(callCount.get()).isZero();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId = new AssetProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ // Profile should still be served from cache without hitting the service again
+ cache.get(tenant, profileId);
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID());
+ AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID());
+
+ AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
+ verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1);
+ }
+
+ // --- Helpers ---
+
+ private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) {
+ AssetProfile profile = new AssetProfile();
+ profile.setId(profileId);
+ profile.setTenantId(tenantId);
+ when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile);
+ cache.get(tenantId, profileId);
+ return profile;
+ }
+
+ private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) {
+ Asset asset = new Asset();
+ asset.setId(assetId);
+ asset.setAssetProfileId(profileId);
+ when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset);
+ cache.get(tenantId, assetId);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
new file mode 100644
index 0000000000..a26413514c
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
@@ -0,0 +1,160 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.service.profile;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.DeviceProfile;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
+import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
+import org.thingsboard.server.dao.device.DeviceProfileService;
+import org.thingsboard.server.dao.device.DeviceService;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class DefaultTbDeviceProfileCacheTest {
+
+ @Mock
+ private DeviceProfileService deviceProfileService;
+ @Mock
+ private DeviceService deviceService;
+
+ private DefaultTbDeviceProfileCache cache;
+
+ @BeforeEach
+ public void setUp() {
+ cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED));
+
+ // After deletion tenant1 profile should be reloaded from service on next get
+ when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant1, profileId1)).isNull();
+ // tenant2 profile should still be served from cache (no extra service call)
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ DeviceId deviceId = new DeviceId(UUID.randomUUID());
+
+ loadProfileIntoCache(tenant, profileId);
+ loadDeviceMappingIntoCache(tenant, deviceId, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload
+ when(deviceService.findDeviceById(any(), any())).thenReturn(null);
+ assertThat(cache.get(tenant, deviceId)).isNull();
+ verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ EntityId listenerId = new DeviceId(UUID.randomUUID());
+ AtomicInteger callCount = new AtomicInteger();
+
+ cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED));
+
+ // Evicting a profile after tenant deletion should not trigger the removed listener
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+ cache.evict(tenant, profileId);
+
+ assertThat(callCount.get()).isZero();
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() {
+ TenantId tenant = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
+ loadProfileIntoCache(tenant, profileId);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED));
+
+ // Profile should still be served from cache without hitting the service again
+ cache.get(tenant, profileId);
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId);
+ }
+
+ @Test
+ public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() {
+ TenantId tenant1 = new TenantId(UUID.randomUUID());
+ TenantId tenant2 = new TenantId(UUID.randomUUID());
+ DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID());
+ DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID());
+
+ DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1);
+ loadProfileIntoCache(tenant2, profileId2);
+
+ cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED));
+
+ assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1);
+ verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1);
+ }
+
+ // --- Helpers ---
+
+ private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) {
+ DeviceProfile profile = new DeviceProfile();
+ profile.setId(profileId);
+ profile.setTenantId(tenantId);
+ when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile);
+ cache.get(tenantId, profileId);
+ return profile;
+ }
+
+ private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) {
+ Device device = new Device();
+ device.setId(deviceId);
+ device.setDeviceProfileId(profileId);
+ when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device);
+ cache.get(tenantId, deviceId);
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
index 54e1a39769..8618e515c8 100644
--- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
+++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
@@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest {
assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull();
}
+ @Test
+ void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler");
+ });
+ }
+
+ @Test
+ void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile");
+ });
+ }
+
+ @Test
+ void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler");
+ });
+ }
+
+ @Test
+ void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile");
+ });
+ }
+
+ @Test
+ void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getMessage()).contains("unresolvable property or identifier: java");
+ });
+ }
+
+ @Test
+ void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
+ UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()");
+ assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
+ .isInstanceOf(ExecutionException.class)
+ .cause()
+ .isInstanceOf(TbScriptException.class)
+ .asInstanceOf(type(TbScriptException.class))
+ .satisfies(ex -> {
+ assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
+ assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider");
+ });
+ }
+
private void assertThatScriptIsBlocked(UUID scriptId) {
assertThatThrownBy(() -> {
invokeScriptResultString(scriptId, "{}");
diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java
new file mode 100644
index 0000000000..a533e0369f
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java
@@ -0,0 +1,277 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.service.ws;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.thingsboard.server.common.data.TenantProfile;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.dao.attributes.AttributesService;
+import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
+import org.thingsboard.server.dao.timeseries.TimeseriesService;
+import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
+import org.thingsboard.server.service.security.AccessValidator;
+import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
+import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService;
+import org.thingsboard.server.service.subscription.TbLocalSubscriptionService;
+import org.thingsboard.server.service.ws.notification.NotificationCommandsHandler;
+import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd;
+
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.willReturn;
+import static org.mockito.Mockito.mock;
+
+class DefaultWebSocketServiceTest {
+
+ DefaultWebSocketService service;
+ TbTenantProfileCache tenantProfileCache;
+ WebSocketMsgEndpoint msgEndpoint;
+
+ @BeforeEach
+ void setUp() {
+ tenantProfileCache = mock(TbTenantProfileCache.class);
+ msgEndpoint = mock(WebSocketMsgEndpoint.class);
+
+ service = new DefaultWebSocketService(
+ mock(TbLocalSubscriptionService.class),
+ mock(TbEntityDataSubscriptionService.class),
+ mock(NotificationCommandsHandler.class),
+ msgEndpoint,
+ mock(AccessValidator.class),
+ mock(AttributesService.class),
+ mock(TimeseriesService.class),
+ mock(TbServiceInfoProvider.class),
+ tenantProfileCache
+ );
+ }
+
+ // Regression test: publicUserSubscriptionsMap must be keyed by TenantId, not UserId(NULL_UUID).
+ // With the old UserId(NULL_UUID) key, all tenants shared one global subscription counter.
+ @Test
+ void processSubscription_publicUserSubscriptionsMap_isPerTenantNotGlobal() throws Exception {
+ int maxPublicSubscriptions = 2;
+
+ TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile1 = new TenantProfile();
+ profile1.createDefaultTenantProfileData();
+ profile1.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile1).given(tenantProfileCache).get(tenant1);
+
+ TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile2 = new TenantProfile();
+ profile2.createDefaultTenantProfileData();
+ profile2.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile2).given(tenantProfileCache).get(tenant2);
+
+ // tenant1 fills up its quota
+ for (int i = 0; i < maxPublicSubscriptions; i++) {
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-" + i), subscriptionCmd(i)))
+ .as("tenant1 subscription %d should be accepted", i + 1)
+ .isTrue();
+ }
+
+ // tenant2 must have its own independent quota — this was the bug:
+ // with UserId(NULL_UUID) as key all tenants shared one counter, so tenant2 would be blocked here
+ for (int i = 0; i < maxPublicSubscriptions; i++) {
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant2, "t2-session-" + i), subscriptionCmd(i)))
+ .as("tenant2 subscription %d should not be affected by tenant1's subscriptions", i + 1)
+ .isTrue();
+ }
+
+ // tenant1's (maxPublicSubscriptions + 1)-th subscription must be rejected
+ assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-over"), subscriptionCmd(99)))
+ .as("tenant1 should be rejected after exceeding its limit")
+ .isFalse();
+
+ // Verify that publicUserSubscriptionsMap has separate entries per tenant
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+
+ assertThat(publicUserSubscriptionsMap).as("map should contain tenant1").containsKey(tenant1);
+ assertThat(publicUserSubscriptionsMap).as("map should contain tenant2").containsKey(tenant2);
+ assertThat(publicUserSubscriptionsMap).as("map must not have a single NULL_UUID entry for all tenants")
+ .doesNotContainKey(new TenantId(EntityId.NULL_UUID));
+
+ assertThat(publicUserSubscriptionsMap.get(tenant1))
+ .as("tenant1 should have exactly %d subscriptions", maxPublicSubscriptions)
+ .hasSize(maxPublicSubscriptions);
+ assertThat(publicUserSubscriptionsMap.get(tenant2))
+ .as("tenant2 should have exactly %d subscriptions", maxPublicSubscriptions)
+ .hasSize(maxPublicSubscriptions);
+ }
+
+ @Test
+ void processSubscription_publicUserSubscriptionsMap_subscriptionIdFormat() {
+ int maxPublicSubscriptions = 5;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "my-session-id";
+ int cmdId = 42;
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+ service.processSubscription(sessionRef, subscriptionCmd(cmdId));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+
+ Set subs = publicUserSubscriptionsMap.get(tenantId);
+ assertThat(subs).hasSize(1);
+ assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]");
+ }
+
+ @Test
+ void processSubscription_unsubscribe_removesEntryFromPublicUserSubscriptionsMap() {
+ int maxPublicSubscriptions = 5;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "session-1";
+ int cmdId = 1;
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+
+ service.processSubscription(sessionRef, subscriptionCmd(cmdId));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(1);
+
+ AttributesSubscriptionCmd unsubCmd = subscriptionCmd(cmdId);
+ unsubCmd.setUnsubscribe(true);
+ service.processSubscription(sessionRef, unsubCmd);
+
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty();
+ }
+
+ @Test
+ void processSubscription_unsubscribe_freesSlotForNewSubscription() {
+ int maxPublicSubscriptions = 1;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, "session-1");
+ service.processSubscription(sessionRef, subscriptionCmd(1));
+
+ // slot is full — second subscription on same session should be rejected
+ assertThat(service.processSubscription(sessionRef, subscriptionCmd(2))).isFalse();
+
+ // unsubscribe cmd 1 to free the slot
+ AttributesSubscriptionCmd unsubCmd = subscriptionCmd(1);
+ unsubCmd.setUnsubscribe(true);
+ service.processSubscription(sessionRef, unsubCmd);
+
+ // now a new subscription should succeed
+ assertThat(service.processSubscription(sessionRef, subscriptionCmd(3)))
+ .as("new subscription should succeed after unsubscribe freed the slot")
+ .isTrue();
+ }
+
+ @Test
+ void processSessionClose_removesAllSessionSubscriptionsFromPublicUserSubscriptionsMap() {
+ int maxPublicSubscriptions = 10;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ String sessionId = "closing-session";
+ WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId);
+
+ service.processSubscription(sessionRef, subscriptionCmd(1));
+ service.processSubscription(sessionRef, subscriptionCmd(2));
+ service.processSubscription(sessionRef, subscriptionCmd(3));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3);
+
+ service.processSessionClose(sessionRef);
+
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty();
+ }
+
+ @Test
+ void processSessionClose_onlyRemovesClosedSessionSubscriptions() {
+ int maxPublicSubscriptions = 10;
+ TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
+ TenantProfile profile = new TenantProfile();
+ profile.createDefaultTenantProfileData();
+ profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions);
+ willReturn(profile).given(tenantProfileCache).get(tenantId);
+
+ WebSocketSessionRef session1 = mockPublicSessionRef(tenantId, "session-1");
+ WebSocketSessionRef session2 = mockPublicSessionRef(tenantId, "session-2");
+
+ service.processSubscription(session1, subscriptionCmd(1));
+ service.processSubscription(session1, subscriptionCmd(2));
+ service.processSubscription(session2, subscriptionCmd(1));
+
+ @SuppressWarnings("unchecked")
+ ConcurrentMap> publicUserSubscriptionsMap =
+ (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
+ assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3);
+
+ service.processSessionClose(session1);
+
+ Set remaining = publicUserSubscriptionsMap.get(tenantId);
+ assertThat(remaining).hasSize(1);
+ assertThat(remaining).allMatch(subId -> subId.startsWith("[session-2]"));
+ }
+
+ private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) {
+ CustomerId customerId = new CustomerId(UUID.randomUUID());
+ SecurityUser securityUser = mock(SecurityUser.class);
+ willReturn(tenantId).given(securityUser).getTenantId();
+ willReturn(customerId).given(securityUser).getCustomerId();
+ willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId();
+ willReturn(true).given(securityUser).isCustomerUser();
+ willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal();
+
+ WebSocketSessionRef ref = mock(WebSocketSessionRef.class);
+ willReturn(securityUser).given(ref).getSecurityCtx();
+ willReturn(sessionId).given(ref).getSessionId();
+ return ref;
+ }
+
+ private AttributesSubscriptionCmd subscriptionCmd(int cmdId) {
+ AttributesSubscriptionCmd cmd = new AttributesSubscriptionCmd();
+ cmd.setCmdId(cmdId);
+ return cmd;
+ }
+
+}
diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
index 67e5e946c8..f2485dc7d5 100644
--- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
+++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
@@ -31,13 +31,21 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
+import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
+import org.thingsboard.server.common.data.widget.WidgetsBundle;
+import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
+import org.thingsboard.server.dao.widget.WidgetsBundleService;
+import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -71,9 +79,18 @@ public class SystemPatchApplierTest {
@Mock
private InstallScripts installScripts;
+ @Mock
+ private DatabaseSchemaSettingsService schemaSettingsService;
+
@Mock
private WidgetTypeService widgetTypeService;
+ @Mock
+ private WidgetsBundleService widgetsBundleService;
+
+ @Mock
+ private ImageService imageService;
+
@InjectMocks
private SystemPatchApplier reconciler;
@@ -145,19 +162,72 @@ public class SystemPatchApplierTest {
}
@Test
- void whenWidgetNotFound_thenThrowException() throws Exception {
+ void whenWidgetNotFound_thenCreateNewWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
- WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget");
- String json = JacksonUtil.toString(testWidget);
+ WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget");
+ String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
- Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
+ Files.writeString(widgetTypesDir.resolve("new_widget.json"), json);
+
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null);
+
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+
+ assertNotNull(stats);
+ assertEquals(1, stats.created());
+ assertEquals(0, stats.updated());
+ verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn())));
+ }
+
+ @Test
+ void whenFqnIsBlank_thenThrowException() throws Exception {
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
- when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null);
+ WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget");
+ String json = JacksonUtil.toString(brokenWidget);
+ assertNotNull(json);
+ Files.writeString(widgetTypesDir.resolve("broken.json"), json);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ }
+
+ @Test
+ void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception {
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+
+ WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New");
+ Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget));
+
+ WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name");
+ Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget));
+
+ WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same");
+ Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget));
+
+ WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name");
+ existingChanged.setId(new WidgetTypeId(UUID.randomUUID()));
+
+ WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same");
+ existingSame.setId(new WidgetTypeId(UUID.randomUUID()));
+
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null);
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged);
+ when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame);
+
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+
+ assertNotNull(stats);
+ assertEquals(1, stats.created());
+ assertEquals(1, stats.updated());
+ verify(widgetTypeService, times(2)).saveWidgetType(any());
}
@Test
@@ -179,9 +249,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(1, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2
));
@@ -204,9 +276,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(1, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
}
@@ -227,9 +301,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- assertEquals(0, updated);
+ assertNotNull(stats);
+ assertEquals(0, stats.created());
+ assertEquals(0, stats.updated());
verify(widgetTypeService, never()).saveWidgetType(any());
}
@@ -329,8 +405,8 @@ public class SystemPatchApplierTest {
// Simulate work while holding lock
Thread.sleep(100);
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- firstThreadSavedWidget.set(updated != null && updated > 0);
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ firstThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@@ -350,8 +426,8 @@ public class SystemPatchApplierTest {
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) {
- Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
- secondThreadSavedWidget.set(updated != null && updated > 0);
+ SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
+ secondThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@@ -373,6 +449,249 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
+ // --- isVersionIncreased tests ---
+
+ @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
+ @MethodSource("provideVersionComparisonTestCases")
+ void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
+ SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
+ assertEquals(expected, result, testName);
+ }
+
+ private static Stream provideVersionComparisonTestCases() {
+ return Stream.of(
+ // Maintenance digit increases within same LTS family
+ Arguments.of("maintenance increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+ Arguments.of("maintenance increased by more than one",
+ new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+
+ // Patch digit increases within same maintenance
+ Arguments.of("patch increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+ Arguments.of("patch increased by more than one",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
+
+ // Both maintenance and patch increased
+ Arguments.of("maintenance and patch both increased",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
+
+ // Maintenance increased, patch value is lower (irrelevant — maintenance wins)
+ Arguments.of("maintenance increased, patch is lower",
+ new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
+
+ // Same version — no increase
+ Arguments.of("same version",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("same version with non-zero parts",
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
+
+ // Decreased versions — no increase
+ Arguments.of("maintenance decreased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
+ Arguments.of("patch decreased",
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
+
+ // Different major — different family, skip
+ Arguments.of("different major",
+ new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("major decreased",
+ new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+
+ // Different minor — different LTS family, skip
+ Arguments.of("minor increased (different LTS family)",
+ new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
+ Arguments.of("minor decreased",
+ new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
+ new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
+ );
+ }
+
+ // --- isVersionChanged tests ---
+
+ @Test
+ void whenVersionIncreased_thenVersionChangedReturnsTrue() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertTrue(result);
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ @Test
+ void whenVersionUnparseable_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ @Test
+ void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad");
+
+ Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged");
+
+ assertFalse(result);
+ }
+
+ // --- updateLtsSqlSchema tests ---
+
+ @Test
+ void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
+ }
+
+ @Test
+ void whenLtsSqlFileDoesNotExist_thenSkips() {
+ Path dataDir = tempDir.resolve("data");
+ // Don't create the file
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate, never()).execute(anyString());
+ }
+
+ @Test
+ void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ String sql = "DO $$ BEGIN\n" +
+ " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
+ " CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
+ " END IF;\n" +
+ "END $$;\n" +
+ "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
+ Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
+
+ verify(jdbcTemplate).execute(sql);
+ }
+
+ // --- applyPatchIfNeeded flow tests ---
+
+ @Test
+ void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
+ Files.createDirectories(ltsDir);
+ Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ // LTS SQL was executed
+ verify(jdbcTemplate).execute("SELECT 1;");
+ // Schema version was updated
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenSkipsEverything() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ // No lock acquired
+ verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
+ // No schema update
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ }
+
+ @Test
+ void whenLockNotAcquired_thenSkipsPatchApplication() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false);
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ verify(jdbcTemplate, never()).execute(anyString());
+ }
+
+ @Test
+ void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenDifferentLtsFamily_thenSkipsPatch() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
+ verify(schemaSettingsService, never()).updateSchemaVersion();
+ }
+
private static Stream provideDescriptorComparisonTestCases() {
return Stream.of(
Arguments.of("Both null", null, null, true),
@@ -407,4 +726,398 @@ public class SystemPatchApplierTest {
return widget;
}
+ // --- createMissingSystemImages tests ---
+
+ @Test
+ void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() {
+ Path dataDir = tempDir.resolve("data");
+ // Intentionally do not create resources/images dir
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService, never()).getAllImageKeysByTenantId(any());
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] imageBytes = new byte[]{1, 2, 3, 4, 5};
+ Files.write(imagesDir.resolve("gateway.png"), imageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes));
+ }
+
+ @Test
+ void whenSystemImageExistsInDb_thenSkipIt() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png"));
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(0, created);
+ verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ @Test
+ void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] newImageBytes = new byte[]{9, 9, 9};
+ byte[] existingImageBytes = new byte[]{1, 1, 1};
+ Files.write(imagesDir.resolve("new.png"), newImageBytes);
+ Files.write(imagesDir.resolve("existing.svg"), existingImageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg"));
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes));
+ verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class));
+ }
+
+ @Test
+ void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ Files.createDirectories(imagesDir.resolve("nested"));
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ byte[] imageBytes = new byte[]{5, 6, 7};
+ Files.write(imagesDir.resolve("logo.png"), imageBytes);
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(1, created);
+ verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes));
+ verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class));
+ }
+
+ @Test
+ void whenMultipleNewImages_thenCreatesAll() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("a.png"), new byte[]{1});
+ Files.write(imagesDir.resolve("b.svg"), new byte[]{2});
+ Files.write(imagesDir.resolve("c.jpg"), new byte[]{3});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages");
+
+ assertEquals(3, created);
+ verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
+ verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class));
+ verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class));
+ verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class));
+ }
+
+ @Test
+ void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+ when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class)))
+ .thenThrow(new RuntimeException("DB error"));
+
+ RuntimeException thrown = assertThrows(RuntimeException.class,
+ () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
+ assertTrue(thrown.getMessage().contains("broken.png"));
+ }
+
+ @Test
+ void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception {
+ Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
+
+ Files.write(imagesDir.resolve("img.png"), new byte[]{1});
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID))
+ .thenThrow(new RuntimeException("lookup failed"));
+
+ assertThrows(RuntimeException.class,
+ () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"));
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ // --- applyPatchIfNeeded integration with createMissingSystemImages ---
+
+ @Test
+ void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() throws Exception {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+ when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
+ when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
+
+ Path dataDir = tempDir.resolve("data");
+ Path imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images");
+ Files.createDirectories(imagesDir);
+ byte[] imgBytes = new byte[]{7, 7, 7};
+ Files.write(imagesDir.resolve("new_icon.svg"), imgBytes);
+ when(installScripts.getDataDir()).thenReturn(dataDir.toString());
+
+ Path widgetTypesDir = tempDir.resolve("widget_types");
+ Files.createDirectories(widgetTypesDir);
+ when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
+
+ when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes));
+ verify(schemaSettingsService).updateSchemaVersion();
+ }
+
+ @Test
+ void whenVersionNotIncreased_thenImagesAreNotTouched() {
+ when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0");
+ when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
+
+ ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
+
+ verify(imageService, never()).getAllImageKeysByTenantId(any());
+ verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
+ }
+
+ // --- updateWidgetBundles tests ---
+
+ @Test
+ void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() {
+ when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\"]}");
+
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null);
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart", "bar_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(1, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService).updateWidgetsBundleWidgetFqns(
+ eq(TenantId.SYS_TENANT_ID),
+ eq(existingBundle.getId()),
+ argThat(fqns -> fqns.size() == 3
+ && fqns.get(0).equals("line_chart")
+ && fqns.get(1).equals("bar_chart")
+ && fqns.get(2).equals("new_chart"))
+ );
+ }
+
+ @Test
+ void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart", "bar_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ // File carries a base64 data URI; DB has the resolved system-image URL — same content, different format.
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," +
+ "\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," +
+ "\"widgetTypeFqns\":[]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ existingBundle.setImage("tb-image;/api/images/system/charts.png");
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of());
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(0, updated);
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleMetadataChanged_thenUpdateBundle() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," +
+ "\"widgetTypeFqns\":[\"line_chart\"]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Old Title");
+ existingBundle.setDescription("old");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+ when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
+ .thenReturn(List.of("line_chart"));
+
+ Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
+
+ assertEquals(1, updated);
+ verify(widgetsBundleService).saveWidgetsBundle(argThat(b ->
+ "New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20
+ ));
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ }
+
+ @Test
+ void whenBundleAliasIsBlank_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("broken.json"),
+ "{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}");
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ @Test
+ void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}");
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ @Test
+ void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception {
+ Path bundlesDir = tempDir.resolve("widget_bundles");
+ Files.createDirectories(bundlesDir);
+ when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
+
+ Files.writeString(bundlesDir.resolve("charts.json"),
+ "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
+ "\"widgetTypes\":[" +
+ "{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" +
+ "]}");
+
+ WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
+ existingBundle.setDescription("d");
+ existingBundle.setOrder(10);
+ when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
+
+ assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
+ verify(widgetTypeService, never()).saveWidgetType(any());
+ verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
+ verify(widgetsBundleService, never()).saveWidgetsBundle(any());
+ }
+
+ private WidgetsBundle createTestBundle(String alias, String title) {
+ WidgetsBundle bundle = new WidgetsBundle();
+ bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
+ bundle.setAlias(alias);
+ bundle.setTitle(title);
+ bundle.setTenantId(TenantId.SYS_TENANT_ID);
+ return bundle;
+ }
+
}
diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
index bf5fb7e448..3e8aef83a1 100644
--- a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
+++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
@@ -18,6 +18,8 @@ package org.thingsboard.server.cache;
import org.springframework.data.redis.serializer.SerializationException;
import org.thingsboard.common.util.JacksonUtil;
+import java.io.IOException;
+
public class TbJsonRedisSerializer implements TbRedisSerializer {
private final Class clazz;
@@ -33,6 +35,13 @@ public class TbJsonRedisSerializer implements TbRedisSerializer {
@Override
public V deserialize(K key, byte[] bytes) throws SerializationException {
- return JacksonUtil.fromBytes(bytes, clazz);
+ if (bytes == null) {
+ return null;
+ }
+ try {
+ return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz);
+ } catch (IOException e) {
+ throw new SerializationException("Failed to deserialize cached value", e);
+ }
}
}
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
index 271866d23c..3b7248ee72 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
@@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
+import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
@@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S
@Slf4j
@Component
@TbCoapServerComponent
-public class DefaultCoapServerService implements CoapServerService {
+public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton {
@Autowired
private CoapServerContext coapServerContext;
private CoapServer server;
- private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
+ private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier;
private ScheduledExecutorService dtlsSessionsExecutor;
+ private volatile DTLSConnector dtlsConnector;
+
+ private volatile CoapEndpoint dtlsCoapEndpoint;
+
@PostConstruct
public void init() throws UnknownHostException {
createCoapServer();
}
+ @Override
+ public void afterSingletonsInstantiated() {
+ if (isDtlsEnabled()) {
+ coapServerContext.getDtlsSettings().registerReloadCallback(() -> {
+ try {
+ log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint...");
+ recreateDtlsEndpoint();
+ log.info("CoAP DTLS endpoint recreated successfully with new certificates.");
+ } catch (Exception e) {
+ log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e);
+ }
+ });
+ }
+ }
+
@PreDestroy
public void shutdown() {
if (dtlsSessionsExecutor != null) {
@@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService {
}
private CoapServer createCoapServer() throws UnknownHostException {
- Configuration networkConfig = new Configuration();
- networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
- networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
- networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
- networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
- networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
- networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
- networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
- networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
- networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
+ Configuration networkConfig = createNetworkConfiguration();
server = new CoapServer(networkConfig);
CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
@@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService {
CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
server.addEndpoint(noSecCoapEndpoint);
if (isDtlsEnabled()) {
- CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
- TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
- DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
- networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
- dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
- DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig);
- dtlsCoapEndpointBuilder.setConnector(connector);
- CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build();
- server.addEndpoint(dtlsCoapEndpoint);
- tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+ createDtlsEndpoint(networkConfig);
dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
}
@@ -137,4 +140,106 @@ public class DefaultCoapServerService implements CoapServerService {
return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout();
}
+ private Configuration createNetworkConfiguration() {
+ Configuration networkConfig = new Configuration();
+ networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true);
+ networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true);
+ networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS);
+ networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024);
+ networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
+ networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024);
+ networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024);
+ networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4);
+ networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort());
+ return networkConfig;
+ }
+
+ // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig.
+ private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException {
+ TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings();
+ DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig);
+ networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort());
+ return dtlsConnectorConfig;
+ }
+
+ private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) {
+ CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder();
+ dtlsCoapEndpointBuilder.setConfiguration(networkConfig);
+ dtlsCoapEndpointBuilder.setConnector(connector);
+ return dtlsCoapEndpointBuilder.build();
+ }
+
+ private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException {
+ DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
+ DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
+ CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
+ server.addEndpoint(newEndpoint);
+
+ dtlsConnector = newConnector;
+ dtlsCoapEndpoint = newEndpoint;
+ tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+ }
+
+ private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) {
+ return new DTLSConnector(config);
+ }
+
+ private synchronized void recreateDtlsEndpoint() throws IOException {
+ CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint;
+ DTLSConnector oldDtlsConnector = dtlsConnector;
+
+ Configuration networkConfig = createNetworkConfiguration();
+
+ log.info("Creating new DTLS endpoint with updated certificates...");
+
+ DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig);
+ DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig);
+ CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector);
+
+ // We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port.
+ // This creates a brief window where the port is unbound;
+ // if the new endpoint fails to start, we attempt to restore the old one (see rollback below).
+ if (oldDtlsEndpoint != null) {
+ log.info("Stopping old DTLS endpoint to release the port...");
+ server.getEndpoints().remove(oldDtlsEndpoint);
+ oldDtlsEndpoint.stop();
+ }
+
+ server.addEndpoint(newEndpoint);
+ try {
+ newEndpoint.start();
+ } catch (IOException e) {
+ log.error("Failed to start new DTLS endpoint, restoring old endpoint", e);
+ server.getEndpoints().remove(newEndpoint);
+ newEndpoint.destroy();
+ newConnector.destroy();
+ // Attempt to restore the old endpoint
+ if (oldDtlsEndpoint != null) {
+ try {
+ server.addEndpoint(oldDtlsEndpoint);
+ oldDtlsEndpoint.start();
+ log.info("Old DTLS endpoint restored successfully.");
+ } catch (IOException restoreEx) {
+ log.error("Failed to restore old DTLS endpoint", restoreEx);
+ }
+ }
+ throw e;
+ }
+ log.info("New DTLS endpoint started successfully.");
+
+ // Only swap instance fields after a successful start
+ dtlsConnector = newConnector;
+ dtlsCoapEndpoint = newEndpoint;
+ tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier();
+
+ // Destroy old resources after a successful swap
+ if (oldDtlsEndpoint != null) {
+ if (oldDtlsConnector != null) {
+ oldDtlsConnector.destroy();
+ }
+ oldDtlsEndpoint.destroy();
+ log.info("Old DTLS endpoint destroyed.");
+ }
+ }
+
}
diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
index af9dc2829f..7f63953fd3 100644
--- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
+++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
@@ -100,6 +100,10 @@ public class TbCoapDtlsSettings {
@Autowired(required = false)
private TbServiceInfoProvider serviceInfoProvider;
+ public void registerReloadCallback(Runnable callback) {
+ coapDtlsCredentialsConfig.registerReloadCallback(callback);
+ }
+
public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException {
DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration);
configBuilder.setAddress(getInetSocketAddress());
@@ -154,5 +158,5 @@ public class TbCoapDtlsSettings {
}
return null;
}
-}
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
new file mode 100644
index 0000000000..11e278b4f5
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
@@ -0,0 +1,349 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.coapserver;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.bouncycastle.util.io.pem.PemWriter;
+import org.eclipse.californium.core.CoapClient;
+import org.eclipse.californium.core.CoapResource;
+import org.eclipse.californium.core.CoapResponse;
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.coap.CoAP;
+import org.eclipse.californium.core.config.CoapConfig;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.server.resources.CoapExchange;
+import org.eclipse.californium.elements.config.Configuration;
+import org.eclipse.californium.elements.util.SslContextUtil;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConfig;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.eclipse.californium.scandium.dtls.CertificateType;
+import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider;
+import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentials;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
+import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType;
+
+import java.io.OutputStreamWriter;
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE;
+import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY;
+
+public class CoapDtlsCertificateReloadIntegrationTest {
+
+ private static final String TEST_RESOURCE_PATH = "test";
+ private static final String TEST_PAYLOAD = "hello-dtls";
+
+ @TempDir
+ Path tempDir;
+
+ private CoapServer coapServer;
+
+ @AfterEach
+ public void teardown() {
+ if (coapServer != null) {
+ coapServer.destroy();
+ }
+ }
+
+ @Test
+ public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception {
+ KeyPair keyPairA = generateKeyPair();
+ X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
+ KeyPair keyPairB = generateKeyPair();
+ X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, certA);
+ writeKeyPem(keyFile, keyPairA);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointA);
+ coapServer.start();
+
+ CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
+ assertThat(responseA).isNotNull();
+ assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD);
+
+ writeCertPem(certFile, certB);
+ writeKeyPem(keyFile, keyPairB);
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpointA);
+ endpointA.stop();
+
+ CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointB);
+ endpointB.start();
+ endpointA.destroy();
+
+ CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
+ assertThat(responseB).isNotNull();
+ assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD);
+ }
+
+ @Test
+ public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception {
+ KeyPair keyPairA = generateKeyPair();
+ X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA");
+ KeyPair keyPairB = generateKeyPair();
+ X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, certA);
+ writeKeyPem(keyFile, keyPairA);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointA);
+ coapServer.start();
+
+ CoapResponse responseA = doDtlsRequest(dtlsPort, certA);
+ assertThat(responseA).isNotNull();
+
+ writeCertPem(certFile, certB);
+ writeKeyPem(keyFile, keyPairB);
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpointA);
+ endpointA.stop();
+ CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpointB);
+ endpointB.start();
+ endpointA.destroy();
+
+ CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA);
+ assertThat(failedResponse).isNull();
+
+ CoapResponse responseB = doDtlsRequest(dtlsPort, certB);
+ assertThat(responseB).isNotNull();
+ assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ }
+
+ @Test
+ public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception {
+ KeyPair keyPair = generateKeyPair();
+ X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server");
+
+ Path certFile = tempDir.resolve("server-cert.pem");
+ Path keyFile = tempDir.resolve("server-key.pem");
+ writeCertPem(certFile, cert);
+ writeKeyPem(keyFile, keyPair);
+
+ SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile);
+
+ Configuration config = createServerConfig();
+ coapServer = new CoapServer(config);
+ coapServer.add(new TestResource());
+
+ int dtlsPort = findAvailablePort();
+ CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpoint1);
+ coapServer.start();
+
+ CoapResponse response1 = doDtlsRequest(dtlsPort, cert);
+ assertThat(response1).isNotNull();
+ assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+
+ credentialsConfig.onCertificateFileChanged();
+
+ coapServer.getEndpoints().remove(endpoint1);
+ endpoint1.stop();
+ CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort);
+ coapServer.addEndpoint(endpoint2);
+ endpoint2.start();
+ endpoint1.destroy();
+
+ CoapResponse response2 = doDtlsRequest(dtlsPort, cert);
+ assertThat(response2).isNotNull();
+ assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT);
+ }
+
+ private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) {
+ PemSslCredentials pem = new PemSslCredentials();
+ pem.setCertFile(certFile.toAbsolutePath().toString());
+ pem.setKeyFile(keyFile.toAbsolutePath().toString());
+
+ SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false);
+ config.setEnabled(true);
+ config.setType(SslCredentialsType.PEM);
+ config.setPem(pem);
+ config.setKeystore(new KeystoreSslCredentials());
+ config.init();
+ return config;
+ }
+
+ private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) {
+ DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config);
+ dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port));
+ dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY);
+ dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS);
+ dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE,
+ org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED);
+
+ SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials(
+ credentials.getPrivateKey(), null, credentials.getCertificateChain());
+
+ dtlsBuilder.setCertificateIdentityProvider(
+ new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(),
+ Collections.singletonList(CertificateType.X_509)));
+
+ dtlsBuilder.setAdvancedCertificateVerifier(
+ StaticNewAdvancedCertificateVerifier.builder()
+ .setTrustAllCertificates()
+ .build());
+
+ DTLSConnector connector = new DTLSConnector(dtlsBuilder.build());
+
+ CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder();
+ endpointBuilder.setConfiguration(config);
+ endpointBuilder.setConnector(connector);
+ return endpointBuilder.build();
+ }
+
+ private KeyPair generateKeyPair() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
+ kpg.initialize(256);
+ return kpg.generateKeyPair();
+ }
+
+ private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception {
+ X500Name subject = new X500Name(subjectDn);
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1));
+ return new JcaX509CertificateConverter().getCertificate(
+ new JcaX509v3CertificateBuilder(
+ subject, BigInteger.valueOf(System.nanoTime()), now, expiry,
+ subject, kp.getPublic())
+ .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate())));
+ }
+
+ private void writeCertPem(Path path, X509Certificate cert) throws Exception {
+ try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
+ writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded()));
+ }
+ }
+
+ private void writeKeyPem(Path path, KeyPair keyPair) throws Exception {
+ try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) {
+ writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded()));
+ }
+ }
+
+ private Configuration createServerConfig() {
+ Configuration config = new Configuration();
+ config.set(CoapConfig.MAX_RETRANSMIT, 2);
+ config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED);
+ return config;
+ }
+
+ private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) {
+ try {
+ Configuration clientConfig = new Configuration();
+ clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1);
+ clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY);
+ clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS);
+ clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false);
+ clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false);
+
+ DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig);
+ clientDtls.setAdvancedCertificateVerifier(
+ StaticNewAdvancedCertificateVerifier.builder()
+ .setTrustedCertificates(trustedCert)
+ .build());
+
+ DTLSConnector clientConnector = new DTLSConnector(clientDtls.build());
+ CoapEndpoint clientEndpoint = new CoapEndpoint.Builder()
+ .setConfiguration(clientConfig)
+ .setConnector(clientConnector)
+ .build();
+
+ CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH);
+ client.setEndpoint(clientEndpoint);
+ client.setTimeout((long) 5000);
+
+ try {
+ clientEndpoint.start();
+ return client.get();
+ } finally {
+ client.shutdown();
+ clientEndpoint.destroy();
+ }
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private int findAvailablePort() throws Exception {
+ try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private static class TestResource extends CoapResource {
+ TestResource() {
+ super(TEST_RESOURCE_PATH);
+ }
+
+ @Override
+ public void handleGET(CoapExchange exchange) {
+ exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD);
+ }
+
+ }
+
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
new file mode 100644
index 0000000000..f22aaf5c7e
--- /dev/null
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
@@ -0,0 +1,246 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.coapserver;
+
+import org.eclipse.californium.core.CoapServer;
+import org.eclipse.californium.core.network.CoapEndpoint;
+import org.eclipse.californium.core.network.Endpoint;
+import org.eclipse.californium.scandium.DTLSConnector;
+import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class CoapDtlsCertificateReloadTest {
+
+ @Mock
+ private CoapServerContext mockCoapServerContext;
+
+ @Mock
+ private TbCoapDtlsSettings mockDtlsSettings;
+
+ @Mock
+ private CoapServer mockCoapServer;
+
+ @Mock
+ private CoapEndpoint mockDtlsEndpoint;
+
+ @Mock
+ private DTLSConnector mockDtlsConnector;
+
+ private DefaultCoapServerService coapServerService;
+
+ @BeforeEach
+ public void setup() {
+ coapServerService = new DefaultCoapServerService();
+ ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext);
+
+ when(mockCoapServerContext.getHost()).thenReturn("localhost");
+ when(mockCoapServerContext.getPort()).thenReturn(5683);
+ doAnswer(invocation -> {
+ invocation.getArgument(0);
+ return null;
+ }).when(mockDtlsSettings).registerReloadCallback(any());
+ }
+
+ @Test
+ public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+ assertThat(callbackCaptor.getValue()).isNotNull();
+ }
+
+ @Test
+ public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
+
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ verify(mockDtlsSettings, never()).registerReloadCallback(any());
+ }
+
+ @Test
+ public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+
+ Runnable reloadCallback = callbackCaptor.getValue();
+ // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw.
+ // The old endpoint should not be stopped/destroyed when creation of the new one fails.
+ reloadCallback.run();
+
+ verify(mockDtlsEndpoint, never()).stop();
+ verify(mockDtlsConnector, never()).destroy();
+ }
+
+ @Test
+ public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+ when(mockCoapServerContext.getHost()).thenReturn("localhost");
+ when(mockCoapServerContext.getPort()).thenReturn(5683);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+
+ verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class));
+ }
+
+ @Test
+ public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() {
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated");
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+
+ Runnable reloadCallback = callbackCaptor.getValue();
+ assertThat(reloadCallback).isNotNull();
+ }
+
+ @Test
+ public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception {
+ // GIVEN
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class);
+ when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ List endpointsList = new CopyOnWriteArrayList<>();
+ endpointsList.add(mockDtlsEndpoint);
+ when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
+
+ CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
+
+ try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class,
+ (builder, context) -> {
+ when(builder.build()).thenReturn(mockNewEndpoint);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ })) {
+
+ // WHEN
+ ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint");
+
+ // THEN
+ assertThat(endpointsList).doesNotContain(mockDtlsEndpoint);
+ verify(mockDtlsEndpoint).stop();
+ verify(mockDtlsEndpoint).destroy();
+ verify(mockDtlsConnector).destroy();
+ verify(mockCoapServer).addEndpoint(mockNewEndpoint);
+ verify(mockNewEndpoint).start();
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint);
+ }
+ }
+
+ @Test
+ public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception {
+ // GIVEN
+ when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
+
+ DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
+ when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684));
+ when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
+
+ ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer);
+ ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint);
+ ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector);
+
+ List endpointsList = new CopyOnWriteArrayList<>();
+ endpointsList.add(mockDtlsEndpoint);
+ when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
+
+ CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
+ doThrow(new IOException("start failed")).when(mockNewEndpoint).start();
+
+ try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class);
+ MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class,
+ (builder, context) -> {
+ when(builder.build()).thenReturn(mockNewEndpoint);
+ when(builder.setConfiguration(any())).thenReturn(builder);
+ when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
+ })) {
+
+ // WHEN
+ coapServerService.afterSingletonsInstantiated();
+
+ ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class);
+ verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture());
+ Runnable reloadCallback = callbackCaptor.getValue();
+ reloadCallback.run();
+
+ // THEN - new resources cleaned up
+ DTLSConnector constructedConnector = dtlsMock.constructed().get(0);
+ verify(mockNewEndpoint).destroy();
+ verify(constructedConnector).destroy();
+ assertThat(endpointsList).doesNotContain(mockNewEndpoint);
+ // Old endpoint was stopped to release port, then restored after new one failed
+ verify(mockDtlsEndpoint).stop();
+ verify(mockDtlsEndpoint).start();
+ // Old fields preserved
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint);
+ assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector);
+ }
+ }
+
+}
diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
index c75348d97e..9538670e25 100644
--- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
+++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
@@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
@@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest {
@Autowired
TbCoapDtlsSettings coapDtlsSettings;
- @MockBean
+ @MockitoBean
SslCredentialsConfig sslCredentialsConfig;
- @MockBean
+ @MockitoBean
private TransportService transportService;
- @MockBean
+ @MockitoBean
private TbServiceInfoProvider serviceInfoProvider;
@Test
diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
index f412c5488a..406263aaed 100644
--- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
+++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
@@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import java.util.Collection;
+import java.util.Set;
public interface ImageService {
@@ -38,6 +39,8 @@ public interface ImageService {
TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key);
+ Set getAllImageKeysByTenantId(TenantId tenantId);
+
TbResourceInfo getPublicImageInfoByKey(String publicResourceKey);
PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink);
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
index 725aa7921c..13c75feed0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
@@ -51,11 +51,9 @@ public class ResourceUtils {
return true;
} else {
try {
- URL url = Resources.getResource(path);
- if (url != null) {
- return true;
- }
- } catch (IllegalArgumentException e) {}
+ Resources.getResource(path);
+ return true;
+ } catch (IllegalArgumentException ignored) {}
}
return false;
}
@@ -93,9 +91,9 @@ public class ResourceUtils {
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
- log.warn("Unable to find resource: " + filePath);
+ log.warn("Unable to find resource: {}", filePath);
} else {
- log.warn("Unable to find resource: " + filePath, e);
+ log.warn("Unable to find resource: {}", filePath, e);
}
}
throw new RuntimeException("Unable to find resource: " + filePath);
@@ -113,15 +111,19 @@ public class ResourceUtils {
return resourceFile.getAbsolutePath();
} else {
URL url = classLoader.getResource(filePath);
+ if (url == null) {
+ throw new RuntimeException("Unable to find resource: " + filePath);
+ }
return url.toURI().toString();
}
} catch (Exception e) {
if (e instanceof NullPointerException) {
- log.warn("Unable to find resource: " + filePath);
+ log.warn("Unable to find resource: {}", filePath);
} else {
- log.warn("Unable to find resource: " + filePath, e);
+ log.warn("Unable to find resource: {}", filePath, e);
}
throw new RuntimeException("Unable to find resource: " + filePath);
}
}
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
index 3cd2ada38f..d3b2e8b73a 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
@@ -45,4 +45,5 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings;
+ boolean allowKeyFiltersOrConditions;
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
index c456323120..65e943c574 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
@@ -39,6 +39,7 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques
private TenantId tenantId;
@Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY)
private CustomerId customerId;
+ @NoXss
@NotNull
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm")
@Length(fieldName = "type")
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
index f14e97a704..578b7909c0 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
@@ -24,6 +24,7 @@ import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyValueType;
import io.swagger.v3.oas.annotations.media.Schema;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
index 390bbb3230..a581fa3a41 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
@@ -22,6 +22,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
index 17c5279b3e..34f128d3e2 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
@@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
-import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java
index 808738a78b..e882c74eb6 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java
@@ -46,7 +46,6 @@ public abstract class TaskResult {
@JsonIgnore
public abstract JobType getJobType();
- @JsonIgnore
public abstract String getError();
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
index 0475a9a02d..9692175876 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
@@ -48,7 +48,7 @@ import java.util.UUID;
@DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class)
})
@JsonIgnoreProperties(ignoreUnknown = true)
-@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY)
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class)
@JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class),
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java
index 23c0241d51..d5f61fdbeb 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java
@@ -93,7 +93,10 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp
@Schema(description = "List of platforms for which usage of the OAuth2 client is allowed (empty for all allowed)")
@Length(fieldName = "platforms")
private List platforms;
- @Schema(description = "Additional info of OAuth2 client (e.g. providerName)", requiredMode = Schema.RequiredMode.REQUIRED)
+ @Schema(description = "Additional info of OAuth2 client. " +
+ "Must include: 'providerName' (string, name of the OAuth2 provider).",
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ example = "{\"providerName\":\"Google\"}")
private JsonNode additionalInfo;
public OAuth2Client() {
@@ -128,4 +131,5 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp
public String getName() {
return title;
}
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
index a678338864..3aabbae0a8 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
@@ -49,4 +49,16 @@ public abstract class AbstractDataQuery extends En
this.latestValues = latestValues;
}
+ public AbstractDataQuery(EntityFilter entityFilter,
+ T pageLink,
+ List entityFields,
+ List latestValues,
+ List keyFilters,
+ ComplexOperation keyFiltersOperation) {
+ super(entityFilter, keyFilters, keyFiltersOperation);
+ this.pageLink = pageLink;
+ this.entityFields = entityFields;
+ this.latestValues = latestValues;
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
index 8431d7a303..d45335021f 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
@@ -51,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery {
super(entityFilter, keyFilters);
}
+ public AlarmCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) {
+ super(entityFilter, keyFilters, keyFiltersOperation);
+ }
+
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
index 8d2ae6fae3..072f56abd1 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
@@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery {
this.alarmFields = alarmFields;
}
+ public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, List alarmFields, ComplexOperation keyFiltersOperation) {
+ super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
+ this.alarmFields = alarmFields;
+ }
+
@JsonIgnore
public AlarmDataQuery next() {
- return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields);
+ return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation());
}
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
index 557f08eb2b..248211ad92 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
@@ -34,9 +34,4 @@ public class ComplexFilterPredicate implements KeyFilterPredicate {
return FilterPredicateType.COMPLEX;
}
- @Schema
- public enum ComplexOperation {
- AND,
- OR
- }
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
similarity index 90%
rename from common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java
rename to common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
index 9ed265f3b8..f08646ec4e 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.thingsboard.server.common.data.alarm.rule.condition.expression;
+package org.thingsboard.server.common.data.query;
import io.swagger.v3.oas.annotations.media.Schema;
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
index b4680bfd5b..ee39fe7d77 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
@@ -34,6 +34,9 @@ public class EntityCountQuery {
@Getter
protected List keyFilters;
+ @Getter
+ private ComplexOperation keyFiltersOperation;
+
public EntityCountQuery() {
}
@@ -45,4 +48,14 @@ public class EntityCountQuery {
this.entityFilter = entityFilter;
this.keyFilters = keyFilters;
}
+
+ public EntityCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) {
+ this.entityFilter = entityFilter;
+ this.keyFilters = keyFilters;
+ this.keyFiltersOperation = keyFiltersOperation;
+ }
+
+ public ComplexOperation getKeyFiltersOperationOrDefault() {
+ return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND;
+ }
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
index 959e3e3af6..f24ffd5821 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
@@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters);
}
+ public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, ComplexOperation keyFiltersOperation) {
+ super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
+ }
+
@JsonIgnore
public EntityDataQuery next() {
- return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters);
+ return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation());
}
}
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
index 3e04074236..18358c8d68 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
@@ -50,7 +50,7 @@ import java.util.List;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
-@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true)
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class)
@JsonSubTypes({
@Type(name = "DEVICE", value = DeviceExportData.class),
@Type(name = "RULE_CHAIN", value = RuleChainExportData.class),
diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
new file mode 100644
index 0000000000..8fb6214dac
--- /dev/null
+++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
@@ -0,0 +1,40 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.common.data;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ResourceUtilsTest {
+
+ @Test
+ public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() {
+ assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Unable to find resource");
+ }
+
+ @Test
+ public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() {
+ String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class");
+
+ assertThat(result).isNotNull();
+ assertThat(result).contains("ResourceUtilsTest");
+ }
+
+}
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
index 8ae3499d63..2d9e77633d 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
@@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint {
this.value = deduplicate ? TbStringPool.intern(value) : value;
}
+ @Override
+ public boolean getBool() {
+ return Boolean.parseBoolean(value);
+ }
+
@Override
public double getDouble() {
return Double.parseDouble(value);
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
index 0d2ed67987..d338362aeb 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query;
import lombok.Builder;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List;
@@ -23,8 +24,8 @@ import java.util.List;
public class EdqsCountQuery extends EdqsQuery {
@Builder
- EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters) {
- super(entityFilter, hasKeyFilters, keyFilters);
+ EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters, ComplexOperation keyFiltersOperation) {
+ super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation);
}
}
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
index 72ccb68515..bd93eca2f1 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
@@ -19,6 +19,7 @@ import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityFilter;
import org.thingsboard.server.common.data.query.EntityKeyType;
@@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery {
private final List latestValues;
@Builder
- public EdqsDataQuery(EntityFilter entityFilter, List keyFilters,
+ public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation,
int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection,
List entityFields, List latestValues) {
- super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters);
+ super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation);
this.pageSize = pageSize;
this.page = page;
this.hasTextSearch = StringUtils.isNotBlank(textSearch);
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
index 9cfc5d442c..8d29ec89b8 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
@@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query;
import lombok.Data;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List;
@@ -26,5 +27,6 @@ public abstract class EdqsQuery {
private final EntityFilter entityFilter;
private final boolean hasKeyFilters;
private final List keyFilters;
+ private final ComplexOperation keyFiltersOperation;
}
diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
index a0a030ba08..6da25c260a 100644
--- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
+++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
@@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint;
import org.thingsboard.server.common.data.permission.QueryContext;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
+import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@@ -60,8 +61,8 @@ import java.util.stream.Stream;
import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
import static org.thingsboard.server.common.data.StringUtils.equalsAny;
import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes;
-import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND;
-import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR;
+import static org.thingsboard.server.common.data.query.ComplexOperation.AND;
+import static org.thingsboard.server.common.data.query.ComplexOperation.OR;
@Slf4j
public class RepositoryUtils {
@@ -139,6 +140,7 @@ public class RepositoryUtils {
}
query.entityFilter(oldQuery.getEntityFilter());
query.keyFilters(toKeyFilters(oldQuery.getKeyFilters()));
+ query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault());
query.entityFields(toNewKeys(oldQuery.getEntityFields()));
query.latestValues(toNewKeys(oldQuery.getLatestValues()));
return query.build();
@@ -149,6 +151,7 @@ public class RepositoryUtils {
.entityFilter(oldQuery.getEntityFilter())
.hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters()))
.keyFilters(toKeyFilters(oldQuery.getKeyFilters()))
+ .keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault())
.build();
}
@@ -196,38 +199,57 @@ public class RepositoryUtils {
}
public static boolean checkKeyFilters(EntityData entity, List keyFilters) {
- for (EdqsFilter keyFilter : keyFilters) {
- EntityKeyValueType valueType = keyFilter.valueType();
- if (valueType == null) {
- valueType = switch (keyFilter.predicate().getType()) {
- case STRING -> EntityKeyValueType.STRING;
- case NUMERIC -> EntityKeyValueType.NUMERIC;
- case BOOLEAN -> EntityKeyValueType.BOOLEAN;
- default -> throw new IllegalStateException();
- };
- }
- DataKey dataKey = keyFilter.key();
- DataPoint dp = entity.getDataPoint(dataKey, null);
- boolean checkResult = switch (valueType) {
- case STRING -> {
- String str = dp != null ? dp.valueToString() : null;
- yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
- (str != null && checkKeyFilter(str, keyFilter.predicate()));
- }
- case BOOLEAN -> {
- Boolean booleanValue = dp != null ? dp.getBool() : null;
- yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
+ return checkKeyFilters(entity, keyFilters, ComplexOperation.AND);
+ }
+
+ public static boolean checkKeyFilters(EntityData entity, List keyFilters, ComplexOperation operation) {
+ ComplexOperation op = operation != null ? operation : ComplexOperation.AND;
+ if (op == ComplexOperation.OR) {
+ for (EdqsFilter keyFilter : keyFilters) {
+ if (evaluateSingleFilter(entity, keyFilter)) {
+ return true;
}
- case DATE_TIME, NUMERIC -> {
- Double doubleValue = dp != null ? dp.getDouble() : null;
- yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate());
+ }
+ // Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters
+ // (which guards on isHasKeyFilters()), but kept defensive for any future direct caller.
+ return keyFilters.isEmpty();
+ } else {
+ for (EdqsFilter keyFilter : keyFilters) {
+ if (!evaluateSingleFilter(entity, keyFilter)) {
+ return false;
}
- };
- if (!checkResult) {
- return false;
}
+ return true;
}
- return true;
+ }
+
+ private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) {
+ EntityKeyValueType valueType = keyFilter.valueType();
+ if (valueType == null) {
+ valueType = switch (keyFilter.predicate().getType()) {
+ case STRING -> EntityKeyValueType.STRING;
+ case NUMERIC -> EntityKeyValueType.NUMERIC;
+ case BOOLEAN -> EntityKeyValueType.BOOLEAN;
+ default -> throw new IllegalStateException();
+ };
+ }
+ DataKey dataKey = keyFilter.key();
+ DataPoint dp = entity.getDataPoint(dataKey, null);
+ return switch (valueType) {
+ case STRING -> {
+ String str = dp != null ? dp.valueToString() : null;
+ yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
+ (str != null && checkKeyFilter(str, keyFilter.predicate()));
+ }
+ case BOOLEAN -> {
+ Boolean booleanValue = dp != null ? dp.getBool() : null;
+ yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
+ }
+ case DATE_TIME, NUMERIC -> {
+ Double doubleValue = dp != null ? dp.getDouble() : null;
+ yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate());
+ }
+ };
}
public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) {
@@ -376,7 +398,7 @@ public class RepositoryUtils {
if (entity == null || entity.getFields() == null) {
return false; // Entity was already removed or not arrived yet;
}
- if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) {
+ if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) {
return false;
}
if (query instanceof EdqsDataQuery dataQuery) {
diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
index 0f50284d6c..2b3f273c70 100644
--- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
+++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
@@ -17,7 +17,7 @@ package org.thingsboard.server.common.msg;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.crypto.digests.SHA3Digest;
-import org.bouncycastle.pqc.legacy.math.linearalgebra.ByteUtils;
+import org.bouncycastle.util.encoders.Hex;
/**
* @author Valerii Sosliuk
@@ -66,7 +66,7 @@ public class EncryptionUtil {
md.update(dataBytes, 0, dataBytes.length);
byte[] hashedBytes = new byte[256 / 8];
md.doFinal(hashedBytes, 0);
- String sha3Hash = ByteUtils.toHexString(hashedBytes);
+ String sha3Hash = Hex.toHexString(hashedBytes);
return sha3Hash;
}
diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java
index 3955abb63f..abfbdcbe30 100644
--- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java
+++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java
@@ -201,18 +201,6 @@ public final class TbMsg implements Serializable {
return builder.build();
}
- @Deprecated(forRemoval = true, since = "4.1") // to be removed in 4.2
- public static TbMsg fromProto(String queueName, TbMsgProto proto, ByteString data, TbMsgCallback callback) {
- try {
- if (!data.isEmpty()) {
- proto = TbMsgProto.parseFrom(data);
- }
- } catch (InvalidProtocolBufferException e) {
- throw new IllegalStateException("Could not parse protobuf for TbMsg", e);
- }
- return fromProto(queueName, proto, callback);
- }
-
public static TbMsg fromProto(String queueName, TbMsgProto proto, TbMsgCallback callback) {
TbMsgMetaData metaData = new TbMsgMetaData(proto.getMetaData().getDataMap());
EntityId entityId = EntityIdFactory.getByTypeAndUuid(proto.getEntityType(), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB()));
diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto
index 70b623f6c6..8cbc2ec7ac 100644
--- a/common/proto/src/main/proto/queue.proto
+++ b/common/proto/src/main/proto/queue.proto
@@ -139,7 +139,6 @@ message SessionInfoProto {
message RestApiCallResponseMsgProto {
int64 requestIdMSB = 1;
int64 requestIdLSB = 2;
- bytes response = 5 [deprecated = true];
msgqueue.TbMsgProto responseProto = 6;
}
diff --git a/common/queue/pom.xml b/common/queue/pom.xml
index 1cb159b3af..a434552363 100644
--- a/common/queue/pom.xml
+++ b/common/queue/pom.xml
@@ -68,10 +68,6 @@
org.apache.kafka
kafka-clients
-
- at.yawk.lz4
- lz4-java
-
com.google.cloud
google-cloud-pubsub
diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java
index 87f566c90f..dcf9459965 100644
--- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java
+++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java
@@ -98,7 +98,10 @@ public abstract class AbstractTbQueueConsumerTemplate i
doSubscribe(partitions);
subscribed = true;
}
- records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis);
+ if (partitions.isEmpty()) {
+ return sleepAndReturnEmpty(startNanos, durationInMillis);
+ }
+ records = doPoll(durationInMillis);
} finally {
consumerLock.unlock();
}
diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java
index bf884958eb..279a2ddc7f 100644
--- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java
+++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java
@@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT;
@@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu
}
private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) {
- Long lastProcessedTs = localCache.get(deduplicationKey);
- if (lastProcessedTs == null && !onlyLocalCache) {
- Cache externalCache = getExternalCache();
- if (externalCache != null) {
- lastProcessedTs = externalCache.get(deduplicationKey, Long.class);
- } else {
- log.warn("Sent notifications cache is not set up");
+ long deduplicationDuration = getDeduplicationDuration(trigger);
+ final long now = System.currentTimeMillis();
+ boolean[] result = {false};
+
+ localCache.compute(deduplicationKey, (key, lastProcessedTs) -> {
+ if (lastProcessedTs == null && !onlyLocalCache) {
+ Cache externalCache = getExternalCache();
+ if (externalCache != null) {
+ lastProcessedTs = externalCache.get(key, Long.class);
+ if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) {
+ log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future",
+ key, lastProcessedTs - now);
+ lastProcessedTs = null;
+ }
+ } else {
+ log.warn("Sent notifications cache is not set up");
+ }
}
- }
- boolean alreadyProcessed = false;
- long deduplicationDuration = getDeduplicationDuration(trigger);
- if (lastProcessedTs != null) {
- long passed = System.currentTimeMillis() - lastProcessedTs;
- log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms",
- trigger.getType(), deduplicationKey, deduplicationDuration, passed);
- if (deduplicationDuration == 0 || passed <= deduplicationDuration) {
- alreadyProcessed = true;
+ if (lastProcessedTs != null) {
+ long passed = now - lastProcessedTs;
+ log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms",
+ trigger.getType(), key, deduplicationDuration, passed);
+ if (deduplicationDuration == 0 || passed <= deduplicationDuration) {
+ result[0] = true;
+ return lastProcessedTs;
+ }
}
- }
- if (!alreadyProcessed) {
- lastProcessedTs = System.currentTimeMillis();
- }
- localCache.put(deduplicationKey, lastProcessedTs);
+ return now;
+ });
+
if (!onlyLocalCache) {
- if (!alreadyProcessed || deduplicationDuration == 0) {
+ if (!result[0] || deduplicationDuration == 0) {
// if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl)
Cache externalCache = getExternalCache();
if (externalCache != null) {
- externalCache.put(deduplicationKey, lastProcessedTs);
+ externalCache.put(deduplicationKey, now);
}
}
}
- return alreadyProcessed;
+ return result[0];
}
public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) {
diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java
new file mode 100644
index 0000000000..a016408ad4
--- /dev/null
+++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java
@@ -0,0 +1,146 @@
+/**
+ * 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.
+ */
+package org.thingsboard.server.queue.common;
+
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.queue.TbQueueMsg;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.BDDMockito.never;
+import static org.mockito.BDDMockito.spy;
+import static org.mockito.BDDMockito.times;
+import static org.mockito.BDDMockito.verify;
+
+@Slf4j
+@ExtendWith(MockitoExtension.class)
+public class AbstractTbQueueConsumerTemplateTest {
+
+ private static final long POLL_DURATION_MS = 100L;
+ private static final long SLEEP_TOLERANCE_MS = 20L;
+
+ @Test
+ public void givenEmptyPartitionsAndLongPollingSupported_whenPoll_thenSleepsAndDoesNotCallDoPoll() {
+ // Regression: with empty partitions AND isLongPollingSupported()==true (e.g. Kafka),
+ // poll() previously returned instantly with no sleep, causing the consumer loop to busy-spin.
+ TestConsumer consumer = spy(new TestConsumer("test-topic", true));
+ consumer.subscribe(Collections.emptySet());
+
+ long startNs = System.nanoTime();
+ List result = consumer.poll(POLL_DURATION_MS);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
+
+ assertThat(result, is(empty()));
+ verify(consumer, never()).doPoll(anyLong());
+ assertThat("poll() must sleep ~durationInMillis when partitions are empty (no busy-wait)",
+ elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
+ }
+
+ @Test
+ public void givenEmptyPartitionsAndNoLongPolling_whenPoll_thenSleepsAndDoesNotCallDoPoll() {
+ TestConsumer consumer = spy(new TestConsumer("test-topic", false));
+ consumer.subscribe(Collections.emptySet());
+
+ long startNs = System.nanoTime();
+ List result = consumer.poll(POLL_DURATION_MS);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
+
+ assertThat(result, is(empty()));
+ verify(consumer, never()).doPoll(anyLong());
+ assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
+ }
+
+ @Test
+ public void givenNonEmptyPartitions_whenPoll_thenCallsDoPoll() {
+ TestConsumer consumer = spy(new TestConsumer("test-topic", true));
+ consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true)));
+
+ List result = consumer.poll(POLL_DURATION_MS);
+
+ assertThat(result, is(empty()));
+ verify(consumer, times(1)).doPoll(POLL_DURATION_MS);
+ }
+
+ @Test
+ public void givenPartitionsBecomeEmptyAfterRebalance_whenPollAgain_thenStopsCallingDoPoll() {
+ // Reproduces the observed trigger: a rebalance leaves the consumer with an empty
+ // partition assignment. Subsequent poll() calls must not busy-spin or call doPoll().
+ TestConsumer consumer = spy(new TestConsumer("test-topic", true));
+ consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true)));
+ consumer.poll(POLL_DURATION_MS);
+ verify(consumer, times(1)).doPoll(POLL_DURATION_MS);
+
+ consumer.subscribe(Collections.emptySet());
+
+ long startNs = System.nanoTime();
+ List result = consumer.poll(POLL_DURATION_MS);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
+
+ assertThat(result, is(empty()));
+ verify(consumer, times(1)).doPoll(anyLong());
+ assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
+ }
+
+ static class TestConsumer extends AbstractTbQueueConsumerTemplate