Browse Source

Merge branch 'master' into sparkplug-any-name-node-unique-device-names

pull/14987/head
nickAS21 5 months ago
parent
commit
51cfa3299b
  1. 23
      application/pom.xml
  2. 3
      application/src/main/data/json/system/widget_bundles/cards.json
  3. 3
      application/src/main/data/json/system/widget_bundles/html_widgets.json
  4. 52
      application/src/main/data/json/system/widget_types/html_container.json
  5. 25
      application/src/main/data/upgrade/lts/schema_update.sql
  6. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  7. 2
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  8. 6
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  9. 192
      application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java
  10. 51
      application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java
  11. 2
      application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
  12. 4
      application/src/main/java/org/thingsboard/server/controller/AssetController.java
  13. 22
      application/src/main/java/org/thingsboard/server/controller/DomainController.java
  14. 22
      application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java
  15. 4
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  16. 11
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  17. 2
      application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java
  18. 6
      application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java
  19. 8
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  20. 13
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  21. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
  22. 126
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  23. 3
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  24. 3
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  25. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
  26. 2
      application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
  27. 6
      application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
  28. 1
      application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
  29. 24
      application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java
  30. 24
      application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java
  31. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  32. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java
  33. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  34. 2
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java
  35. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
  36. 35
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  37. 4
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java
  38. 2
      application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java
  39. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
  40. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java
  41. 7
      application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
  42. 205
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  43. 14
      application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java
  44. 34
      application/src/main/resources/thingsboard.yml
  45. 53
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  46. 28
      application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
  47. 4
      application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
  48. 4
      application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
  49. 69
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  50. 47
      application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
  51. 497
      application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
  52. 527
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  53. 73
      application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java
  54. 8
      application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
  55. 144
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  56. 517
      application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java
  57. 105
      application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
  58. 126
      application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
  59. 159
      application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java
  60. 160
      application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java
  61. 84
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
  62. 277
      application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java
  63. 743
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
  64. 11
      common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java
  65. 149
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
  66. 6
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  67. 349
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java
  68. 246
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java
  69. 8
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java
  70. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
  71. 20
      common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java
  72. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  73. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java
  74. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
  75. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
  76. 2
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
  77. 1
      common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java
  78. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
  79. 6
      common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java
  80. 12
      common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
  81. 4
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
  82. 7
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
  83. 5
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
  84. 2
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
  85. 13
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
  86. 6
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
  87. 2
      common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
  88. 40
      common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java
  89. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
  90. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
  91. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
  92. 2
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
  93. 84
      common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
  94. 4
      common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java
  95. 12
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java
  96. 1
      common/proto/src/main/proto/queue.proto
  97. 4
      common/queue/pom.xml
  98. 5
      common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java
  99. 56
      common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java
  100. 146
      common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java

23
application/pom.xml

@ -572,6 +572,29 @@
<outputDir>${project.build.directory}</outputDir>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>verify-openapi-spec-generated</id>
<phase>verify</phase>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireFilesExist>
<files>
<file>${project.build.directory}/openapi.json</file>
</files>
<message>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).</message>
</requireFilesExist>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>

3
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"
]
}

3
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"
]
}

52
application/src/main/data/json/system/widget_types/html_container.json

File diff suppressed because one or more lines are too long

25
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

5
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;

2
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<String, ArgumentEntry> 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);

6
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());

192
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<String, Schema> 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<String, Object> 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<String, Schema> allSchemas,
Map<String, Object> result, Set<String> 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<String, Schema> allSchemas,
Set<String> 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<String, Object> 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<String, Schema> allSchemas, Set<String> ownProps) {
if (schema.getAllOf() == null) {

51
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;
}
}

2
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());
}

4
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<Asset> processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception {
public BulkImportResult<Asset> processAssetBulkImport(@RequestBody BulkImportRequest request) throws Exception {
SecurityUser user = getCurrentUser();
return assetBulkImportService.processBulkImport(request, user);
}

22
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<DomainInfo> 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<DomainInfo> 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);

22
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<OAuth2ClientInfo> 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<OAuth2ClientInfo> 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);
}

4
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;
}

11
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')")

2
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<WidgetType> getBundleWidgetTypes(
@Parameter(description = "Widget Bundle Id", required = true)
@PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException {

6
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<TenantId, Set<String>> tenantSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<CustomerId, Set<String>> customerSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UserId, Set<String>> regularUserSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UserId, Set<String>> publicUserSessionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantId, Set<String>> publicUserSessionsMap = new ConcurrentHashMap<>();
private Cache<String, SessionMetaData> 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<String> publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
Set<String> 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<String> publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
Set<String> publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet());
synchronized (publicUserSessions) {
publicUserSessions.remove(sessionId);
}

8
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 <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> 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);
}

13
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;
}

2
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);

126
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<EntityId> removedEntities = ownerEntities.remove(owner);
if (removedEntities != null) {
Set<EntityId> removedCustomers = removedEntities
.stream()
.filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER)
.collect(Collectors.toSet());
removedCustomers.forEach(this::evictOwner);
}
}
private Set<EntityId> 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<EntityId>();
var removedLinkEntityIds = new HashSet<EntityId>();
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<CalculatedFieldLink> 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<CalculatedField> cfs = entityIdCalculatedFields.remove(entityId);
if (cfs != null) {
var cfIds = new HashSet<CalculatedFieldId>();
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());
}

3
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;

3
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);
}
}

12
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;
}

2
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;

6
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<Path, JsonNode> widgetsBundlesMap = new HashMap<>();
Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(
path -> {

1
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.");
}

24
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<AssetProfileId> 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<EntityId, Consumer<AssetProfile>> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {

24
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<DeviceProfileId> 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<EntityId, Consumer<DeviceProfile>> tenantListeners = profileListeners.get(profile.getTenantId());
if (tenantListeners != null) {

4
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

4
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;

4
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<ToCore
TbImageService imageService,
TbResourceDataCache tbResourceDataCache,
RuleEngineCallService ruleEngineCallService,
CalculatedFieldCache calculatedFieldCache,
EdqsService edqsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService,
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService,
eventPublisher, jwtSettingsService);
this.stateService = stateService;
this.localSubscriptionService = localSubscriptionService;

2
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java

@ -87,7 +87,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService<ToEdge
public DefaultTbEdgeConsumerService(TbCoreQueueFactory tbCoreQueueFactory, ActorSystemContext actorContext,
StatsFactory statsFactory, EdgeContextComponent edgeCtx) {
super(actorContext, null, null, null, null, null, null, null, null, null);
super(actorContext, null, null, null, null, null, null, null, null);
this.edgeCtx = edgeCtx;
this.stats = new EdgeConsumerStats(statsFactory);
this.queueFactory = tbCoreQueueFactory;

4
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java

@ -46,7 +46,6 @@ import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
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.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.queue.processing.AbstractPartitionBasedConsumerService;
@ -86,9 +85,8 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService,
CalculatedFieldCache calculatedFieldCache,
TbMsgPackProcessingContextFactory packProcessingContextFactory) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService;

35
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -26,7 +26,6 @@ import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.EntityType;
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;
@ -47,7 +46,6 @@ import org.thingsboard.server.queue.discovery.TbApplicationEventListener;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.queue.TbPackCallback;
@ -75,7 +73,6 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
protected final TbDeviceProfileCache deviceProfileCache;
protected final TbAssetProfileCache assetProfileCache;
protected final TbResourceDataCache tbResourceDataCache;
protected final CalculatedFieldCache calculatedFieldCache;
protected final TbApiUsageStateService apiUsageStateService;
protected final PartitionService partitionService;
protected final ApplicationEventPublisher eventPublisher;
@ -166,7 +163,6 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
tenantProfileCache.evict(tenantProfileId);
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED)) {
apiUsageStateService.onTenantProfileUpdate(tenantProfileId);
calculatedFieldCache.handleTenantProfileUpdate(tenantProfileId);
}
} else if (EntityType.TENANT.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (TenantId.SYS_TENANT_ID.equals(tenantId)) {
@ -179,7 +175,6 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
apiUsageStateService.onTenantUpdate(tenantId);
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.DELETED)) {
apiUsageStateService.onTenantDelete(tenantId);
calculatedFieldCache.evictOwner(tenantId);
partitionService.removeTenant(tenantId);
}
}
@ -187,45 +182,17 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
deviceProfileCache.evict(tenantId, new DeviceProfileId(componentLifecycleMsg.getEntityId().getId()));
} else if (EntityType.DEVICE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
deviceProfileCache.evict(tenantId, new DeviceId(componentLifecycleMsg.getEntityId().getId()));
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.CREATED)) {
calculatedFieldCache.addOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED) && componentLifecycleMsg.isOwnerChanged()) {
calculatedFieldCache.updateOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.DELETED)) {
calculatedFieldCache.evictEntity(componentLifecycleMsg.getEntityId());
}
} else if (EntityType.ASSET_PROFILE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
assetProfileCache.evict(tenantId, new AssetProfileId(componentLifecycleMsg.getEntityId().getId()));
} else if (EntityType.ASSET.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
assetProfileCache.evict(tenantId, new AssetId(componentLifecycleMsg.getEntityId().getId()));
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.CREATED)) {
calculatedFieldCache.addOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED) && componentLifecycleMsg.isOwnerChanged()) {
calculatedFieldCache.updateOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.DELETED)) {
calculatedFieldCache.evictEntity(componentLifecycleMsg.getEntityId());
}
} else if (EntityType.ENTITY_VIEW.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
actorContext.getTbEntityViewService().onComponentLifecycleMsg(componentLifecycleMsg);
} else if (EntityType.API_USAGE_STATE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
apiUsageStateService.onApiUsageStateUpdate(tenantId);
} else if (EntityType.CUSTOMER.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.CREATED)) {
calculatedFieldCache.addOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED) && componentLifecycleMsg.isOwnerChanged()) {
calculatedFieldCache.updateOwnerEntity(tenantId, componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.DELETED) {
if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.DELETED) {
apiUsageStateService.onCustomerDelete((CustomerId) componentLifecycleMsg.getEntityId());
calculatedFieldCache.evictOwner(componentLifecycleMsg.getEntityId());
calculatedFieldCache.evictEntity(componentLifecycleMsg.getEntityId());
}
} else if (EntityType.CALCULATED_FIELD.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.CREATED) {
calculatedFieldCache.addCalculatedField(tenantId, (CalculatedFieldId) componentLifecycleMsg.getEntityId());
} else if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.UPDATED) {
calculatedFieldCache.updateCalculatedField(tenantId, (CalculatedFieldId) componentLifecycleMsg.getEntityId());
} else {
calculatedFieldCache.evict((CalculatedFieldId) componentLifecycleMsg.getEntityId());
}
} else if (EntityType.TB_RESOURCE.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
tbResourceDataCache.evictResourceData(tenantId, new TbResourceId(componentLifecycleMsg.getEntityId().getId()));

4
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractPartitionBasedConsumerService.java

@ -24,7 +24,6 @@ import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import org.thingsboard.server.service.cf.CalculatedFieldCache;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
@ -45,12 +44,11 @@ public abstract class AbstractPartitionBasedConsumerService<N extends com.google
TbDeviceProfileCache deviceProfileCache,
TbAssetProfileCache assetProfileCache,
TbResourceDataCache tbResourceDataCache,
CalculatedFieldCache calculatedFieldCache,
TbApiUsageStateService apiUsageStateService,
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService) {
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
}
@PostConstruct

2
application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java

@ -73,7 +73,7 @@ public class DefaultRuleEngineCallService implements RuleEngineCallService {
UUID requestId = new UUID(restApiCallResponseMsg.getRequestIdMSB(), restApiCallResponseMsg.getRequestIdLSB());
Consumer<TbMsg> 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);
}

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java

@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx<AlarmCountQu
private EntityDataQuery buildEntityDataQuery() {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
private void resetInvocationCounter() {

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java

@ -363,7 +363,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx<AlarmDataQuery> {
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());
}
}

7
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<EntityType> SUPPORTED_ENTITY_TYPES = List.of(
public static final List<EntityType> 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<EntityType> 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")

205
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<Path> 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<Path> 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<String> 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<String> existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
LinkedHashSet<String> 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<String> existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID);
try (Stream<Path> 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 }
}

14
application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java

@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService {
private final ConcurrentMap<TenantId, Set<String>> tenantSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<CustomerId, Set<String>> customerSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UserId, Set<String>> regularUserSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UserId, Set<String>> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Map<Integer, Integer>> 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<String> publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
Set<String> 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<String> publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet());
Set<String> 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"

34
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.

53
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<String, Argument> arguments = Map.of(
"temperature", temperatureArgument
);
long staticDurationMs = 5000L;
Map<AlarmSeverity, Condition> 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();

28
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);

4
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());
}

4
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());
}

69
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

47
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<AlarmCommentInfo> systemCommentOpt = doGetTyped(
"/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference<PageData<AlarmCommentInfo>>() {
}
).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<AlarmCommentInfo> systemCommentOpt = doGetTyped(
"/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference<PageData<AlarmCommentInfo>>() {
}
).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)))

497
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));

527
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<EntityKey> nameEntityField() {
return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
}
private static List<String> extractNames(PageData<EntityData> 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<KeyFilter> 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<KeyFilter> 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<EntityData> 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<KeyFilter> 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<EntityData> 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<KeyFilter> 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<EntityData> 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<KeyFilter> 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<KeyFilter> 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<EntityKey> 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<AlarmData> alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
List<String> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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<EntityData> 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<EntityData> 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<String> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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<KeyFilter> 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);
}
}

73
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());

8
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);

144
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);
}
}

517
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;
}
}

105
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<Device> 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<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> 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<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> 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<String> 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<String> 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<Device> 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<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> 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<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> 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<String> 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<String> 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<EntityData> findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) {
return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query),

126
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<Device> 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<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> 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<Device> 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<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> 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<String> expectedAsc, List<String> 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<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> 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<String> 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<String> 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<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Long value) {
protected ListenableFuture<TimeseriesSaveResult> 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<TimeseriesSaveResult> 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<Asset> buildings, List<Asset> apartments,
Map<String, Map<UUID, String>> entityNameByTypeMap,
Map<UUID, UUID> childParentRelationMap) throws InterruptedException {

159
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);
}
}

160
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);
}
}

84
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, "{}");

277
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<TenantId, Set<String>> publicUserSubscriptionsMap =
(ConcurrentMap<TenantId, Set<String>>) 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<TenantId, Set<String>> publicUserSubscriptionsMap =
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
Set<String> 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<TenantId, Set<String>> publicUserSubscriptionsMap =
(ConcurrentMap<TenantId, Set<String>>) 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<TenantId, Set<String>> publicUserSubscriptionsMap =
(ConcurrentMap<TenantId, Set<String>>) 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<TenantId, Set<String>> publicUserSubscriptionsMap =
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap");
assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3);
service.processSessionClose(session1);
Set<String> 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;
}
}

743
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<Arguments> 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<Arguments> 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;
}
}

11
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<K, V> implements TbRedisSerializer<K, V> {
private final Class<V> clazz;
@ -33,6 +35,13 @@ public class TbJsonRedisSerializer<K, V> implements TbRedisSerializer<K, V> {
@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);
}
}
}

149
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.");
}
}
}

6
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;
}
}
}

349
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);
}
}
}

246
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<Runnable> 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<Runnable> 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<Runnable> 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<Endpoint> endpointsList = new CopyOnWriteArrayList<>();
endpointsList.add(mockDtlsEndpoint);
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList);
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class);
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class);
MockedConstruction<CoapEndpoint.Builder> 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<Endpoint> 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<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class);
MockedConstruction<CoapEndpoint.Builder> 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<Runnable> 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);
}
}
}

8
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

3
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<String> getAllImageKeysByTenantId(TenantId tenantId);
TbResourceInfo getPublicImageInfoByKey(String publicResourceKey);
PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink);

20
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);
}
}
}

1
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;
}

1
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")

1
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;

1
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;

2
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;

1
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();
}

2
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),

6
common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java

@ -93,7 +93,10 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo<OAuth2ClientId> imp
@Schema(description = "List of platforms for which usage of the OAuth2 client is allowed (empty for all allowed)")
@Length(fieldName = "platforms")
private List<PlatformType> 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<OAuth2ClientId> imp
public String getName() {
return title;
}
}

12
common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java

@ -49,4 +49,16 @@ public abstract class AbstractDataQuery<T extends EntityDataPageLink> extends En
this.latestValues = latestValues;
}
public AbstractDataQuery(EntityFilter entityFilter,
T pageLink,
List<EntityKey> entityFields,
List<EntityKey> latestValues,
List<KeyFilter> keyFilters,
ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
this.pageLink = pageLink;
this.entityFields = entityFields;
this.latestValues = latestValues;
}
}

4
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<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
}
}

7
common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java

@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery<AlarmDataPageLink> {
this.alarmFields = alarmFields;
}
public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> keyFilters, List<EntityKey> 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());
}
}

5
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
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java → 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;

13
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java

@ -34,6 +34,9 @@ public class EntityCountQuery {
@Getter
protected List<KeyFilter> 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<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
this.entityFilter = entityFilter;
this.keyFilters = keyFilters;
this.keyFiltersOperation = keyFiltersOperation;
}
public ComplexOperation getKeyFiltersOperationOrDefault() {
return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND;
}
}

6
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java

@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery<EntityDataPageLink> {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters);
}
public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> 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());
}
}

2
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),

40
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");
}
}

5
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);

5
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<EdqsFilter> keyFilters) {
super(entityFilter, hasKeyFilters, keyFilters);
EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation);
}
}

5
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<DataKey> latestValues;
@Builder
public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters,
public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation,
int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection,
List<DataKey> entityFields, List<DataKey> 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);

2
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<EdqsFilter> keyFilters;
private final ComplexOperation keyFiltersOperation;
}

84
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<EdqsFilter> 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<EdqsFilter> 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) {

4
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;
}

12
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()));

1
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;
}

4
common/queue/pom.xml

@ -68,10 +68,6 @@
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>at.yawk.lz4</groupId>
<artifactId>lz4-java</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-pubsub</artifactId>

5
common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java

@ -98,7 +98,10 @@ public abstract class AbstractTbQueueConsumerTemplate<R, T extends TbQueueMsg> i
doSubscribe(partitions);
subscribed = true;
}
records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis);
if (partitions.isEmpty()) {
return sleepAndReturnEmpty(startNanos, durationInMillis);
}
records = doPoll(durationInMillis);
} finally {
consumerLock.unlock();
}

56
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) {

146
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<TbQueueMsg> 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<TbQueueMsg> 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<TbQueueMsg> 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<TbQueueMsg> 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<Object, TbQueueMsg> {
private final boolean longPollingSupported;
TestConsumer(String topic, boolean longPollingSupported) {
super(topic);
this.longPollingSupported = longPollingSupported;
}
@Override
protected List<Object> doPoll(long durationInMillis) {
return Collections.emptyList();
}
@Override
protected TbQueueMsg decode(Object record) {
return null;
}
@Override
protected void doSubscribe(Set<TopicPartitionInfo> partitions) {
}
@Override
protected void doCommit() {
}
@Override
protected void doUnsubscribe() {
}
@Override
protected boolean isLongPollingSupported() {
return longPollingSupported;
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save