Browse Source

Merge remote-tracking branch 'upstream/lts-4.3' into chore/build-warnings-cleanup-lts-4.3

# Conflicts:
#	dao/src/test/java/org/thingsboard/server/dao/service/validator/DashboardDataValidatorTest.java
pull/15661/head
Oleksandra Matviienko 4 months ago
parent
commit
4c8d1347df
  1. 16
      application/pom.xml
  2. 2
      application/src/main/data/json/system/widget_types/html_container.json
  3. 22
      application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java
  4. 12
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  5. 41
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  6. 115
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  8. 5
      application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java
  9. 6
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  10. 4
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java
  11. 2
      application/src/main/resources/thingsboard.yml
  12. 2
      application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
  13. 318
      application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java
  14. 23
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  15. 47
      application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java
  16. 4
      application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java
  17. 1
      application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
  18. 329
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  19. 4
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  20. 141
      application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java
  21. 32
      application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java
  22. 78
      application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java
  23. 2
      common/actor/pom.xml
  24. 2
      common/cache/pom.xml
  25. 2
      common/cluster-api/pom.xml
  26. 2
      common/coap-server/pom.xml
  27. 62
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java
  28. 145
      common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java
  29. 2
      common/dao-api/pom.xml
  30. 2
      common/data/pom.xml
  31. 7
      common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
  32. 2
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  33. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
  34. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
  35. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
  36. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
  37. 9
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
  38. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
  39. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
  40. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
  41. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
  42. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
  43. 2
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java
  44. 12
      common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java
  45. 2
      common/discovery-api/pom.xml
  46. 2
      common/edge-api/pom.xml
  47. 2
      common/edge-api/src/main/proto/edge.proto
  48. 2
      common/edqs/pom.xml
  49. 2
      common/message/pom.xml
  50. 2
      common/pom.xml
  51. 2
      common/proto/pom.xml
  52. 7
      common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
  53. 2
      common/proto/src/main/proto/queue.proto
  54. 11
      common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
  55. 2
      common/queue/pom.xml
  56. 2
      common/script/pom.xml
  57. 2
      common/script/remote-js-client/pom.xml
  58. 2
      common/script/script-api/pom.xml
  59. 2
      common/stats/pom.xml
  60. 2
      common/transport/coap/pom.xml
  61. 2
      common/transport/http/pom.xml
  62. 2
      common/transport/lwm2m/pom.xml
  63. 25
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java
  64. 115
      common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java
  65. 2
      common/transport/mqtt/pom.xml
  66. 2
      common/transport/pom.xml
  67. 2
      common/transport/snmp/pom.xml
  68. 2
      common/transport/transport-api/pom.xml
  69. 115
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java
  70. 35
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java
  71. 74
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java
  72. 4
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  73. 76
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java
  74. 202
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java
  75. 64
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java
  76. 191
      common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java
  77. 2
      common/util/pom.xml
  78. 6
      common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java
  79. 11
      common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java
  80. 2
      common/version-control/pom.xml
  81. 2
      dao/pom.xml
  82. 4
      dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java
  83. 18
      dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java
  84. 40
      dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java
  85. 31
      dao/src/test/java/org/thingsboard/server/dao/service/validator/DashboardDataValidatorTest.java
  86. 138
      dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java
  87. 123
      dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java
  88. 2
      edqs/pom.xml
  89. 2
      monitoring/pom.xml
  90. 2
      msa/black-box-tests/pom.xml
  91. 2
      msa/edqs/pom.xml
  92. 2
      msa/js-executor/package.json
  93. 2
      msa/js-executor/pom.xml
  94. 2
      msa/monitoring/pom.xml
  95. 2
      msa/pom.xml
  96. 2
      msa/tb-node/pom.xml
  97. 2
      msa/tb/pom.xml
  98. 2
      msa/transport/coap/pom.xml
  99. 2
      msa/transport/http/pom.xml
  100. 2
      msa/transport/lwm2m/pom.xml

16
application/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>application</artifactId> <artifactId>application</artifactId>
@ -395,11 +395,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.thingsboard.langchain4j</groupId> <groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-google-ai-gemini</artifactId> <artifactId>langchain4j-google-genai</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-vertex-ai-gemini</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.thingsboard.langchain4j</groupId> <groupId>org.thingsboard.langchain4j</groupId>
@ -415,13 +411,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.thingsboard.langchain4j</groupId> <groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-github-models</artifactId> <artifactId>langchain4j-open-ai-official</artifactId>
<exclusions>
<exclusion>
<groupId>com.azure</groupId>
<artifactId>azure-core-test</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.thingsboard.langchain4j</groupId> <groupId>org.thingsboard.langchain4j</groupId>

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

@ -11,7 +11,7 @@
"resources": [], "resources": [],
"templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>", "templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>",
"templateCss": "", "templateCss": "",
"controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}",
"settingsDirective": "tb-html-container-widget-settings", "settingsDirective": "tb-html-container-widget-settings",
"hasBasicMode": true, "hasBasicMode": true,
"basicModeDirective": "tb-html-container-basic-config", "basicModeDirective": "tb-html-container-basic-config",

22
application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java

@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo;
import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AlarmId;
@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Operation;
import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION;
@ -77,9 +79,13 @@ public class AlarmCommentController extends BaseController {
checkParameter(ALARM_ID, strAlarmId); checkParameter(ALARM_ID, strAlarmId);
AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); AlarmId alarmId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE);
SecurityUser currentUser = getCurrentUser();
if (alarmComment.getId() != null) {
checkUserPermission(alarmComment, alarmId, "edit", currentUser);
}
alarmComment.setAlarmId(alarmId); alarmComment.setAlarmId(alarmId);
alarmComment.setType(AlarmCommentType.OTHER); alarmComment.setType(AlarmCommentType.OTHER);
return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser);
} }
@ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)",
@ -93,7 +99,11 @@ public class AlarmCommentController extends BaseController {
AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId));
AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId);
tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); SecurityUser currentUser = getCurrentUser();
if (!currentUser.isTenantAdmin()) {
checkUserPermission(alarmComment, alarmId, "delete", currentUser);
}
tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser);
} }
@ApiOperation(value = "Get Alarm comments (getAlarmComments)", @ApiOperation(value = "Get Alarm comments (getAlarmComments)",
@ -120,4 +130,12 @@ public class AlarmCommentController extends BaseController {
return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink));
} }
private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException {
AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId);
if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) {
throw new ThingsboardException("User is not allowed to " + operation + " other user's comment",
ThingsboardErrorCode.PERMISSION_DENIED);
}
}
} }

12
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettings;
import org.thingsboard.server.common.data.settings.UserSettingsType; import org.thingsboard.server.common.data.settings.UserSettingsType;
import org.thingsboard.server.common.msg.edqs.EdqsService;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.mobile.QrCodeSettingService;
import org.thingsboard.server.dao.trendz.TrendzSettingsService; import org.thingsboard.server.dao.trendz.TrendzSettingsService;
@ -52,6 +53,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Hidden @Hidden
@ -76,6 +78,11 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}") @Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes; private int defaultDebugDurationMinutes;
@Value("${sql.entity_data_query_nulls_order_strategy:default}")
private String nullsOrderStrategy;
private static final Set<String> ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of("default", "nulls_first", "nulls_last");
@Autowired(required = false) @Autowired(required = false)
private BuildProperties buildProperties; private BuildProperties buildProperties;
@ -91,6 +98,9 @@ public class SystemInfoController extends BaseController {
@Autowired @Autowired
private TrendzSettingsService trendzSettingsService; private TrendzSettingsService trendzSettingsService;
@Autowired
private EdqsService edqsService;
@PostConstruct @PostConstruct
public void init() { public void init() {
JsonNode info = buildInfoObject(); JsonNode info = buildInfoObject();
@ -150,6 +160,8 @@ public class SystemInfoController extends BaseController {
} }
systemParams.setUserSettings(userSettingsNode); systemParams.setUserSettings(userSettingsNode);
systemParams.setMaxDatapointsLimit(maxDatapointsLimit); systemParams.setMaxDatapointsLimit(maxDatapointsLimit);
systemParams.setNullsOrderStrategy(ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy) ? nullsOrderStrategy : "default");
systemParams.setEdqsEnabled(edqsService.isApiEnabled());
if (!currentUser.isSystemAdmin()) { if (!currentUser.isSystemAdmin()) {
DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration();
systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize());

41
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java

@ -15,14 +15,8 @@
*/ */
package org.thingsboard.server.service.ai; 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.FluentFuture;
import com.google.common.util.concurrent.Futures; 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;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.ChatResponse;
@ -31,9 +25,6 @@ import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import java.util.List;
import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
class AiChatModelServiceImpl implements AiChatModelService { class AiChatModelServiceImpl implements AiChatModelService {
@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService {
} catch (Throwable t) { } catch (Throwable t) {
return FluentFuture.from(Futures.immediateFailedFuture(t)); return FluentFuture.from(Futures.immediateFailedFuture(t));
} }
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}
return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest);
} }
private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) {
List<ChatMessage> messages = chatRequest.messages().stream()
.map(this::prepareUserMessage)
.collect(Collectors.toList());
return ChatRequest.builder()
.messages(messages)
.responseFormat(chatRequest.responseFormat())
.build();
}
private ChatMessage prepareUserMessage(ChatMessage message) {
if (message instanceof UserMessage userMessage) {
List<Content> newContents = userMessage.contents().stream()
.map(this::prepareContent)
.collect(Collectors.toList());
return UserMessage.from(newContents);
}
return message;
}
private Content prepareContent(Content content) {
if (content instanceof TextContent txt) {
return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text())));
}
return content;
}
} }

115
application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java

@ -15,26 +15,18 @@
*/ */
package org.thingsboard.server.service.ai; package org.thingsboard.server.service.ai;
import com.google.api.gax.core.FixedCredentialsProvider; import com.google.auth.oauth2.GoogleCredentials;
import com.google.api.gax.retrying.RetrySettings;
import com.google.auth.oauth2.ServiceAccountCredentials; import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.vertexai.Transport;
import com.google.cloud.vertexai.VertexAI;
import com.google.cloud.vertexai.api.GenerationConfig;
import com.google.cloud.vertexai.api.PredictionServiceClient;
import com.google.cloud.vertexai.api.PredictionServiceSettings;
import com.google.cloud.vertexai.generativeai.GenerativeModel;
import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.anthropic.AnthropicChatModel;
import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.azure.AzureOpenAiChatModel;
import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.bedrock.BedrockChatModel;
import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.chat.request.ChatRequestParameters;
import dev.langchain4j.model.github.GitHubModelsChatModel; import dev.langchain4j.model.google.genai.GoogleGenAiChatModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.common.util.SsrfProtectionValidator;
@ -50,7 +42,6 @@ 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.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; 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.OllamaProviderConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@ -107,7 +98,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) {
return GoogleAiGeminiChatModel.builder() return GoogleGenAiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey()) .apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId()) .modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature()) .temperature(chatModelConfig.temperature())
@ -123,84 +114,28 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) {
GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); GoogleCredentials credentials;
// construct service account credentials using service account key JSON
ServiceAccountCredentials serviceAccountCredentials;
try { try {
serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); credentials = ServiceAccountCredentials
.fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8)))
.createScoped("https://www.googleapis.com/auth/cloud-platform");
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Failed to parse service account key JSON", e); throw new RuntimeException("Failed to parse service account key JSON", e);
} }
return GoogleGenAiChatModel.builder()
PredictionServiceSettings predictionServiceClientSettings; .projectId(chatModelConfig.providerConfig().projectId())
try { .location(chatModelConfig.providerConfig().location())
// create prediction service settings for REST transport with service account key credentials .googleCredentials(credentials)
PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() .modelName(chatModelConfig.modelId())
.setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); .temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
// get the retry settings that control request timeout for generateContent RPC .topK(chatModelConfig.topK())
RetrySettings.Builder retrySettings = settingsBuilder .frequencyPenalty(chatModelConfig.frequencyPenalty())
.generateContentSettings() .presencePenalty(chatModelConfig.presencePenalty())
.getRetrySettings() .maxOutputTokens(chatModelConfig.maxOutputTokens())
.toBuilder(); .timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
// set request timeout from model config
if (chatModelConfig.timeoutSeconds() != null) {
retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
}
// set updated retry settings
settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build());
// build the client settings
predictionServiceClientSettings = settingsBuilder.build();
} catch (IOException e) {
throw new RuntimeException("Failed to create prediction service client settings", e);
}
// construct Vertex AI instance
var vertexAI = new VertexAI.Builder()
.setProjectId(providerConfig.projectId())
.setLocation(providerConfig.location())
.setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings))
.setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys
.build(); .build();
// map model config to generation config
var generationConfigBuilder = GenerationConfig.newBuilder();
if (chatModelConfig.temperature() != null) {
generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue());
}
if (chatModelConfig.topP() != null) {
generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue());
}
if (chatModelConfig.topK() != null) {
generationConfigBuilder.setTopK(chatModelConfig.topK());
}
if (chatModelConfig.frequencyPenalty() != null) {
generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
}
if (chatModelConfig.presencePenalty() != null) {
generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
}
if (chatModelConfig.maxOutputTokens() != null) {
generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());
}
var generationConfig = generationConfigBuilder.build();
// construct generative model instance
var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig);
return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries());
}
private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) {
try {
return PredictionServiceClient.create(settings);
} catch (IOException e) {
throw new RuntimeException("Failed to create prediction service client", e);
}
} }
@Override @Override
@ -262,14 +197,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) {
return GitHubModelsChatModel.builder() return OpenAiOfficialChatModel.builder()
.gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) .isGitHubModels(true)
.strictJsonSchema(true)
.apiKey(chatModelConfig.providerConfig().personalAccessToken())
.modelName(chatModelConfig.modelId()) .modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature()) .temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP()) .topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty()) .frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty()) .presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens()) .maxCompletionTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds())) .timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries()) .maxRetries(chatModelConfig.maxRetries())
.build(); .build();

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java

@ -67,6 +67,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
ObjectNode valuesNode = JacksonUtil.newObjectNode(); ObjectNode valuesNode = JacksonUtil.newObjectNode();
if (result instanceof Double doubleValue) { if (result instanceof Double doubleValue) {
valuesNode.put(outputName, doubleValue); valuesNode.put(outputName, doubleValue);
} else if (result instanceof Long longValue) {
valuesNode.put(outputName, longValue);
} else if (result instanceof Integer integerValue) { } else if (result instanceof Integer integerValue) {
valuesNode.put(outputName, integerValue); valuesNode.put(outputName, integerValue);
} else { } else {

5
application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.queue.TbQueueAdmin; import org.thingsboard.server.queue.TbQueueAdmin;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private final QueueService queueService; private final QueueService queueService;
private final TbClusterService tbClusterService; private final TbClusterService tbClusterService;
private final TbQueueAdmin tbQueueAdmin; private final TbQueueAdmin tbQueueAdmin;
private final TopicService topicService;
@Override @Override
public Queue saveQueue(Queue queue) { public Queue saveQueue(Queue queue) {
@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb
private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { private void createTopicsIfNeeded(Queue queue, Queue oldQueue) {
int newPartitions = queue.getPartitions(); int newPartitions = queue.getPartitions();
int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0;
String topic = topicService.buildTopicName(queue.getTopic());
for (int i = oldPartitions; i < newPartitions; i++) { for (int i = oldPartitions; i < newPartitions; i++) {
tbQueueAdmin.createTopicIfNotExists( tbQueueAdmin.createTopicIfNotExists(
new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(),
queue.getCustomProperties(), queue.getCustomProperties(),
true); // forcing topic creation because the topic may still be cached on some nodes true); // forcing topic creation because the topic may still be cached on some nodes
} }

6
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -464,10 +464,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
return firmwareStateService.process(msg.getValue()); return firmwareStateService.process(msg.getValue());
} }
private void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) {
RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
, proto.getResponse(), error); , proto.hasResponse() ? proto.getResponse() : null, error);
tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response);
callback.onSuccess(); callback.onSuccess();
} }

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

@ -179,9 +179,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo
callback.onSuccess(); callback.onSuccess();
} else if (nfMsg.hasFromDeviceRpcResponse()) { } else if (nfMsg.hasFromDeviceRpcResponse()) {
TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse();
RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; RpcError error = RpcError.fromProtoErrorCode(proto.getError());
FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB())
, proto.getResponse(), error); , proto.hasResponse() ? proto.getResponse() : null, error);
tbDeviceRpcService.processRpcResponseFromDevice(response); tbDeviceRpcService.processRpcResponseFromDevice(response);
callback.onSuccess(); callback.onSuccess();
} else if (nfMsg.getQueueUpdateMsgsCount() > 0) { } else if (nfMsg.getQueueUpdateMsgsCount() > 0) {

2
application/src/main/resources/thingsboard.yml

@ -1158,6 +1158,8 @@ transport:
timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}"
# Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property) # Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property)
api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" api_enabled: "${TB_TRANSPORT_API_ENABLED:true}"
# Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently.
callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}"
log: log:
# Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update
enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" enabled: "${TB_TRANSPORT_LOG_ENABLED:true}"

2
application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java

@ -100,7 +100,7 @@ public class AlarmCommentApiClientTest extends AbstractApiClientTest {
.filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId)) .filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId))
.findFirst() .findFirst()
.get(); .get();
assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText()); assertEquals("Comment was deleted by user " + clientTenantAdmin.getEmail(), deletedComment.getComment().get("text").asText());
} }
} }

318
application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java

@ -0,0 +1,318 @@
/**
* 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.client;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.ThingsboardClient;
import org.thingsboard.client.model.ApiKeyInfo;
import org.thingsboard.client.model.Asset;
import org.thingsboard.client.model.AttributeData;
import org.thingsboard.client.model.BooleanFilterPredicate;
import org.thingsboard.client.model.BooleanOperation;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntityCountQuery;
import org.thingsboard.client.model.EntityKey;
import org.thingsboard.client.model.EntityKeyType;
import org.thingsboard.client.model.EntityKeyValueType;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.EntityTypeFilter;
import org.thingsboard.client.model.FilterPredicateValueBoolean;
import org.thingsboard.client.model.KeyFilter;
import org.thingsboard.client.model.PageDataDevice;
import org.thingsboard.client.model.TsData;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Mirrors every code snippet from the Java client documentation page
* ({@code /docs/reference/java-client/}, CE edition). Each snippet appears
* character-for-character with two allowances:
* <ul>
* <li>placeholder values ({@code "{BASE_URL}"}, {@code "YOUR_API_KEY_VALUE"},
* {@code "YOUR_DEVICE_ID"}, {@code "YOUR_ASSET_ID"},
* {@code "nonexistent-id"}, {@code "tenant@thingsboard.org"},
* {@code "tenant"}) are swapped for real test values;</li>
* <li>{@code System.out.println} / {@code System.out.printf} calls inside the
* snippet are replaced with equivalent JUnit assertions, so the test
* actually verifies behavior instead of only compilation.</li>
* </ul>
* Setup code that pre-creates entities required by a snippet and post-snippet
* verifications stay outside the snippet block.
*/
@DaoSqlTest
public class ClientDocsExampleTest extends AbstractApiClientTest {
// /docs/reference/java-client/#quickstart
@Test
public void testQuickstart() throws Exception {
// setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder
ApiKeyInfo keyRequest = new ApiKeyInfo();
keyRequest.setDescription("ClientDocsExampleTest");
keyRequest.setUserId(clientTenantAdmin.getId());
keyRequest.setEnabled(true);
String apiKeyValue = this.client.saveApiKey(keyRequest).getValue();
// === doc snippet ===
ThingsboardClient client = ThingsboardClient.builder()
.url(getBaseUrl())
.apiKey(apiKeyValue)
.build();
Device newDevice = new Device();
newDevice.setName("Quickstart Device");
newDevice.setType("default");
Device savedDevice = client.saveDevice(newDevice, null, null, null, null);
String deviceId = savedDevice.getId().getId().toString();
client.saveEntityTelemetry("DEVICE", deviceId, "ANY", """
{"temperature": 22.4}
""");
assertEquals("Quickstart Device", savedDevice.getName());
client.deleteDevice(deviceId);
// post-snippet verification: the device is gone after deletion
assertReturns404(() -> client.getDeviceById(deviceId));
}
// /docs/reference/java-client/#api-key-recommended
@Test
public void testAuthenticationViaApiKey() throws Exception {
// setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder
ApiKeyInfo keyRequest = new ApiKeyInfo();
keyRequest.setDescription("ClientDocsExampleTest");
keyRequest.setUserId(clientTenantAdmin.getId());
keyRequest.setEnabled(true);
String apiKeyValue = this.client.saveApiKey(keyRequest).getValue();
// === doc snippet ===
String url = getBaseUrl();
String apiKey = apiKeyValue;
ThingsboardClient client = ThingsboardClient.builder()
.url(url)
.apiKey(apiKey)
.build();
assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
}
// /docs/reference/java-client/#username-and-password-jwt
@Test
public void testAuthenticationViaCredentials() throws Exception {
// === doc snippet ===
String url = getBaseUrl();
ThingsboardClient client = ThingsboardClient.builder()
.url(url)
.credentials(TENANT_ADMIN_USERNAME, TEST_PASSWORD)
.build();
assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
}
// /docs/reference/java-client/#rate-limit-handling
@Test
public void testRateLimitHandlingBuilderOptions() throws Exception {
// setup: real url + api key that the snippet references as locals
String url = getBaseUrl();
ApiKeyInfo keyRequest = new ApiKeyInfo();
keyRequest.setDescription("ClientDocsExampleTest");
keyRequest.setUserId(clientTenantAdmin.getId());
keyRequest.setEnabled(true);
String apiKey = this.client.saveApiKey(keyRequest).getValue();
// === doc snippet ===
ThingsboardClient client = ThingsboardClient.builder()
.url(url)
.apiKey(apiKey)
.maxRetries(3) // default 3
.initialRetryDelayMs(1000) // default 1 s
.maxRetryDelayMs(30_000) // default 30 s
.build();
// post-snippet verification: the tuned client is actually usable
assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail());
}
// /docs/reference/java-client/#working-with-entities
@Test
public void testWorkingWithEntities() throws Exception {
// === doc snippet ===
Device newDevice = new Device();
newDevice.setName("Test Device");
newDevice.setType("default");
Device savedDevice = client.saveDevice(newDevice, null, null, null, null);
String deviceId = savedDevice.getId().getId().toString();
Device fetched = client.getDeviceById(deviceId);
assertEquals("Test Device", fetched.getName());
client.deleteDevice(deviceId);
// post-snippet verification: the device is gone after deletion
assertReturns404(() -> client.getDeviceById(deviceId));
}
// /docs/reference/java-client/#push-telemetry
@Test
public void testPushTelemetry() throws Exception {
// setup: create a real device whose id replaces "YOUR_DEVICE_ID"
Device setup = new Device();
setup.setName("Telemetry Setup Device");
setup.setType("default");
String realDeviceId = client.saveDevice(setup, null, null, null, null)
.getId().getId().toString();
// === doc snippet ===
String deviceId = realDeviceId;
String body = """
{"temperature": 26.5, "humidity": 87}
""";
client.saveEntityTelemetry("DEVICE", deviceId, "ANY", body);
// post-snippet verification: telemetry was actually persisted
Map<String, List<TsData>> latest =
client.getLatestTimeseries("DEVICE", deviceId, "temperature,humidity", false, null);
assertEquals("26.5", latest.get("temperature").get(0).getValue().toString());
assertEquals("87", latest.get("humidity").get(0).getValue().toString());
}
// /docs/reference/java-client/#read-and-write-attributes-read-modify-write
@Test
public void testReadModifyWriteAttributes() throws Exception {
// setup: create a real asset whose id replaces "YOUR_ASSET_ID"
Asset setupAsset = new Asset();
setupAsset.setName("Counter Setup Asset");
setupAsset.setType("building");
String realAssetId = client.saveAsset(setupAsset, null, null, null)
.getId().getId().toString();
// === doc snippet ===
String assetId = realAssetId;
List<AttributeData> attrs = client.getAttributesByScope(
"ASSET", assetId, "SERVER_SCOPE", "deviceCount", null);
// getValue() returns Object — JSON numbers come back as Number subclasses
long current = attrs.isEmpty() ? 0L : ((Number) attrs.get(0).getValue()).longValue();
long updated = current + 1;
client.saveEntityAttributesV2("ASSET", assetId, "SERVER_SCOPE",
"{\"deviceCount\": %d}".formatted(updated));
// post-snippet verification: the increment was actually persisted
List<AttributeData> after = client.getAttributesByScope(
"ASSET", assetId, "SERVER_SCOPE", "deviceCount", null);
assertEquals(1, after.size());
assertEquals(updated, ((Number) after.get(0).getValue()).longValue());
}
// /docs/reference/java-client/#paginated-tenant-list
@Test
public void testPaginatedTenantList() throws Exception {
// setup: populate the tenant with a few devices so the iteration has something to walk
int expectedDeviceCount = 5;
for (int i = 0; i < expectedDeviceCount; i++) {
Device d = new Device();
d.setName("Page Setup Device " + i);
d.setType("default");
client.saveDevice(d, null, null, null, null);
}
// === doc snippet ===
int page = 0; // pages are zero-indexed
PageDataDevice devices;
do {
devices = client.getTenantDevices(100, page, null, null, null, null);
devices.getData().forEach(d -> assertEquals("default", d.getType()));
page++;
} while (devices.getHasNext());
// post-snippet verification: pagination terminated and reached every device
assertEquals((long) expectedDeviceCount, devices.getTotalElements().longValue());
}
// /docs/reference/java-client/#filtered-query-with-entity-data-query-api
@Test
public void testEntityDataQueryCountFiltered() throws Exception {
// setup: create a mix of active and inactive devices for the count query
Device active1 = client.saveDevice(
new Device().name("Active_1").type("default"),
null, null, null, null);
Device active2 = client.saveDevice(
new Device().name("Active_2").type("default"),
null, null, null, null);
client.saveDevice(
new Device().name("Inactive_1").type("default"),
null, null, null, null);
client.saveEntityAttributesV2("DEVICE", active1.getId().getId().toString(),
"SERVER_SCOPE", "{\"active\": true}");
client.saveEntityAttributesV2("DEVICE", active2.getId().getId().toString(),
"SERVER_SCOPE", "{\"active\": true}");
// === doc snippet ===
EntityTypeFilter typeFilter = new EntityTypeFilter();
typeFilter.setEntityType(EntityType.DEVICE);
EntityCountQuery totalQuery = new EntityCountQuery();
totalQuery.setEntityFilter(typeFilter);
assertEquals(3L, client.countEntitiesByQuery(totalQuery).longValue());
KeyFilter activeFilter = new KeyFilter();
activeFilter.setKey(new EntityKey().type(EntityKeyType.ATTRIBUTE).key("active"));
activeFilter.setValueType(EntityKeyValueType.BOOLEAN);
BooleanFilterPredicate predicate = new BooleanFilterPredicate();
predicate.setOperation(BooleanOperation.EQUAL);
predicate.setValue(new FilterPredicateValueBoolean().defaultValue(true));
activeFilter.setPredicate(predicate);
EntityCountQuery activeQuery = new EntityCountQuery();
activeQuery.setEntityFilter(typeFilter);
activeQuery.setKeyFilters(List.of(activeFilter));
assertEquals(2L, client.countEntitiesByQuery(activeQuery).longValue());
}
// /docs/reference/java-client/#error-handling
@Test
public void testErrorHandling404() {
// setup: a real (random) UUID that doesn't resolve, replacing "nonexistent-id";
// the flag captures whether the 404 branch ran so we can assert the snippet
// actually entered error handling (instead of silently completing).
String missingDeviceId = UUID.randomUUID().toString();
boolean[] caught404 = {false};
// === doc snippet ===
try {
Device device = client.getDeviceById(missingDeviceId);
} catch (ApiException e) {
if (e.getCode() == 404) {
caught404[0] = true;
} else {
fail("API error " + e.getCode() + ": " + e.getResponseBody());
}
}
// post-snippet verification: the snippet actually exercised the 404 branch
assertTrue("Expected ApiException with code 404", caught404[0]);
}
}

23
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -229,6 +229,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer"; private static final String CUSTOMER_USER_PASSWORD = "customer";
protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org";
@ -268,6 +269,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected CustomerId differentTenantCustomerId; protected CustomerId differentTenantCustomerId;
protected UserId customerUserId; protected UserId customerUserId;
protected UserId secondCustomerUserId;
protected UserId differentCustomerUserId; protected UserId differentCustomerUserId;
protected UserId differentTenantCustomerUserId; protected UserId differentTenantCustomerUserId;
@ -393,9 +395,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
customerUser.setCustomerId(savedCustomer.getId()); customerUser.setCustomerId(savedCustomer.getId());
customerUser.setEmail(CUSTOMER_USER_EMAIL); customerUser.setEmail(CUSTOMER_USER_EMAIL);
customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD);
customerUserId = customerUser.getId(); customerUserId = customerUser.getId();
User secondCustomerUser = new User();
secondCustomerUser.setAuthority(Authority.CUSTOMER_USER);
secondCustomerUser.setTenantId(tenantId);
secondCustomerUser.setCustomerId(customerId);
secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL);
secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD);
secondCustomerUserId = secondCustomerUser.getId();
resetTokens(); resetTokens();
log.debug("Executed web test setup"); log.debug("Executed web test setup");
@ -494,6 +504,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
} }
protected void loginSecondCustomerUser() throws Exception {
login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD);
}
protected void loginUser(String userName, String password) throws Exception { protected void loginUser(String userName, String password) throws Exception {
login(userName, password); login(userName, password);
} }
@ -608,6 +622,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return savedUser; return savedUser;
} }
protected User createUserAndActivate(User user, String password) throws Exception {
User savedUser = doPost("/api/user", user, User.class);
JsonNode activateRequest = getActivateRequest(password);
doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk());
return savedUser;
}
protected User createUser(User user, String password) throws Exception { protected User createUser(User user, String password) throws Exception {
User savedUser = doPost("/api/user", user, User.class); User savedUser = doPost("/api/user", user, User.class);
JsonNode activateRequest = getActivateRequest(password); JsonNode activateRequest = getActivateRequest(password);

47
application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java

@ -161,6 +161,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment);
} }
@Test
public void testEditOthersAlarmCommentIsProhibited() throws Exception {
loginCustomerUser();
AlarmComment alarmComment = createAlarmComment(alarm.getId());
JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite"));
alarmComment.setComment(newComment);
loginSecondCustomerUser();
doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
.andExpect(status().isForbidden())
.andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
loginTenantAdmin();
doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment)
.andExpect(status().isForbidden())
.andExpect(statusReason(containsString("User is not allowed to edit other user's comment")));
}
@Test @Test
public void testUpdateAlarmViaDifferentTenant() throws Exception { public void testUpdateAlarmViaDifferentTenant() throws Exception {
loginTenantAdmin(); loginTenantAdmin();
@ -218,6 +237,32 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
} }
@Test
public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception {
loginCustomerUser();
AlarmComment alarmComment = createAlarmComment(alarm.getId());
loginSecondCustomerUser();
Mockito.reset(tbClusterService, auditLogService);
doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
.andExpect(status().isForbidden())
.andExpect(statusReason(containsString("User is not allowed to delete other user's comment")));
loginTenantAdmin();
doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId())
.andExpect(status().isOk());
AlarmComment expectedAlarmComment = AlarmComment.builder()
.alarmId(alarm.getId())
.type(AlarmCommentType.SYSTEM)
.comment(JacksonUtil.newObjectNode()
.put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL))
.put("subtype", COMMENT_DELETED.name())
.put("userName", TENANT_ADMIN_EMAIL))
.build();
testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment);
}
@Test @Test
public void testDeleteAlarmViaTenant() throws Exception { public void testDeleteAlarmViaTenant() throws Exception {
loginTenantAdmin(); loginTenantAdmin();
@ -237,7 +282,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest {
assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); assertThat(systemComment.getId()).isEqualTo(alarmComment.getId());
assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM);
assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s",
TENANT_ADMIN_EMAIL)); TENANT_ADMIN_EMAIL));
AlarmComment expectedAlarmComment = AlarmComment.builder() AlarmComment expectedAlarmComment = AlarmComment.builder()

4
application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java

@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest {
Assert.assertEquals(1, usageInfo.getCustomers()); Assert.assertEquals(1, usageInfo.getCustomers());
Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers()); Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers());
Assert.assertEquals(2, usageInfo.getUsers()); Assert.assertEquals(3, usageInfo.getUsers());
Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers()); Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers());
Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards()); Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards());
@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest {
} }
usageInfo = doGet("/api/usage", UsageInfo.class); usageInfo = doGet("/api/usage", UsageInfo.class);
Assert.assertEquals(users.size() + 2, usageInfo.getUsers()); Assert.assertEquals(users.size() + 3, usageInfo.getUsers());
List<Dashboard> dashboards = new ArrayList<>(); List<Dashboard> dashboards = new ArrayList<>();
for (int i = 0; i < 97; i++) { for (int i = 0; i < 97; i++) {

1
application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java

@ -717,6 +717,7 @@ public class UserControllerTest extends AbstractControllerTest {
String email = "testEmail1"; String email = "testEmail1";
List<UserId> expectedCustomerUserIds = new ArrayList<>(); List<UserId> expectedCustomerUserIds = new ArrayList<>();
expectedCustomerUserIds.add(customerUserId); expectedCustomerUserIds.add(customerUserId);
expectedCustomerUserIds.add(secondCustomerUserId);
for (int i = 0; i < 45; i++) { for (int i = 0; i < 45; i++) {
User customerUser = createCustomerUser(customerId); User customerUser = createCustomerUser(customerId);
customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org"); customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org");

329
application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java

@ -15,20 +15,29 @@
*/ */
package org.thingsboard.server.service.ai; package org.thingsboard.server.service.ai;
import com.google.cloud.vertexai.api.GenerationConfig; import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequestParameters;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator; 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; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; 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.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
@BeforeEach
void enableSsrfProtection() {
SsrfProtectionValidator.setEnabled(true);
}
@AfterEach @AfterEach
void disableSsrfProtection() { void resetSsrfProtection() {
SsrfProtectionValidator.setEnabled(false); SsrfProtectionValidator.setEnabled(false);
} }
// ============================== Configuration correctness (one per provider) ==============================
// For each provider we feed a fully populated config and assert that the returned ChatModel carries the same
// values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection.
@Test
void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() {
// GIVEN
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("https://api.openai.com/v1")
.apiKey("test-key")
.build())
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() {
// GIVEN
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key"))
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() {
// GIVEN
var config = GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-key"))
.modelId("gemini-2.5-flash")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() {
// GIVEN
var config = GoogleVertexAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleVertexAiGeminiProviderConfig(
"key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY))
.modelId("gemini-2.5-flash")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() {
// GIVEN
var config = MistralAiChatModelConfig.builder()
.providerConfig(new MistralAiProviderConfig("test-key"))
.modelId("mistral-large-latest")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("mistral-large-latest");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() {
// GIVEN
var config = AnthropicChatModelConfig.builder()
.providerConfig(new AnthropicProviderConfig("test-key"))
.modelId("claude-opus-4-8")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("claude-opus-4-8");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test @Test
void configureChatModel_openAi_withPrivateIp_shouldThrow() { void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() {
// GIVEN
var config = AmazonBedrockChatModelConfig.builder()
.providerConfig(new AmazonBedrockProviderConfig(
"us-east-1", "test-access-key-id", "test-secret-access-key"))
.modelId("anthropic.claude-3-5-sonnet-20240620-v1:0")
.temperature(0.7)
.topP(0.9)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() {
// GIVEN
var config = GitHubModelsChatModelConfig.builder()
.providerConfig(new GitHubModelsProviderConfig("ghp-test-token"))
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens
}
@Test
void shouldConfigureOllamaModel_whenGivenOllamaConfig() {
// GIVEN
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.temperature(0.7)
.topP(0.9)
.topK(40)
.contextLength(4096)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("llama3");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens
}
// ============================== Base URL SSRF validation ==============================
// Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses
// when SSRF protection is enabled.
@Test
void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder() var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder() .providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/") .baseUrl("http://172.17.0.1:8080/")
@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o") .modelId("gpt-4o")
.build(); .build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config)) assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class) .isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid"); .hasMessageContaining("URI is invalid");
} }
@Test @Test
void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { void shouldThrow_whenOpenAiBaseUrlIsLocalhost() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder() var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder() .providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/") .baseUrl("http://localhost:22/")
@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o") .modelId("gpt-4o")
.build(); .build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config)) assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class) .isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid"); .hasMessageContaining("URI is invalid");
} }
@Test @Test
void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = AzureOpenAiChatModelConfig.builder() var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig( .providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key")) "http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o") .modelId("gpt-4o")
.build(); .build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config)) assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class) .isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid"); .hasMessageContaining("URI is invalid");
} }
@Test @Test
void configureChatModel_ollama_withPrivateIp_shouldThrow() { void shouldThrow_whenOllamaBaseUrlIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OllamaChatModelConfig.builder() var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig( .providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3") .modelId("llama3")
.build(); .build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config)) assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class) .isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid"); .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);
}
} }

4
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java

@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput(); Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope()); assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L)));
} }
@Test @Test
@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest {
Output output = getCalculatedFieldConfig().getOutput(); Output output = getCalculatedFieldConfig().getOutput();
assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope()); assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L)));
} }
@Test @Test

141
application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java

@ -0,0 +1,141 @@
/**
* 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.entitiy.queue;
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.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.id.QueueId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.queue.Queue;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.queue.TbQueueAdmin;
import org.thingsboard.server.queue.discovery.TopicService;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultTbQueueServiceTest {
@Mock
private QueueService queueServiceMock;
@Mock
private TbClusterService tbClusterServiceMock;
@Mock
private TbQueueAdmin tbQueueAdminMock;
private TopicService topicService;
private DefaultTbQueueService tbQueueService;
private final TenantId tenantId = TenantId.SYS_TENANT_ID;
@BeforeEach
public void setUp() {
topicService = new TopicService();
tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService);
}
private Queue newQueue(int partitions) {
Queue queue = new Queue();
queue.setTenantId(tenantId);
queue.setName("testQueue");
queue.setTopic("tb_rule_engine.testQueue");
queue.setPartitions(partitions);
return queue;
}
@Test
public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() {
// queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set)
ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
Queue queue = newQueue(2);
when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
tbQueueService.saveQueue(queue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
// All created topics must carry the prefix - this is the fix.
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"thingsboard.tb_rule_engine.testQueue.0",
"thingsboard.tb_rule_engine.testQueue.1");
// No unprefixed (orphan-prone) topic must ever be created.
assertThat(topicCaptor.getAllValues())
.noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0")
|| topic.equals("tb_rule_engine.testQueue.1"));
}
@Test
public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() {
// queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved
ReflectionTestUtils.setField(topicService, "prefix", "");
Queue queue = newQueue(2);
when(queueServiceMock.saveQueue(queue)).thenReturn(queue);
tbQueueService.saveQueue(queue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"tb_rule_engine.testQueue.0",
"tb_rule_engine.testQueue.1");
}
@Test
public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() {
ReflectionTestUtils.setField(topicService, "prefix", "thingsboard");
Queue oldQueue = newQueue(2);
oldQueue.setId(new QueueId(UUID.randomUUID()));
Queue updatedQueue = newQueue(4);
updatedQueue.setId(oldQueue.getId());
when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue);
when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue);
tbQueueService.saveQueue(updatedQueue);
ArgumentCaptor<String> topicCaptor = ArgumentCaptor.forClass(String.class);
verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean());
assertThat(topicCaptor.getAllValues())
.containsExactlyInAnyOrder(
"thingsboard.tb_rule_engine.testQueue.2",
"thingsboard.tb_rule_engine.testQueue.3");
verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean());
}
}

32
application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java

@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rpc.RpcError;
import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService;
import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.ruleengine.RuleEngineCallService;
import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.state.DeviceStateService;
@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest {
private TbCoreConsumerStats statsMock; private TbCoreConsumerStats statsMock;
@Mock @Mock
private RuleEngineCallService ruleEngineCallServiceMock; private RuleEngineCallService ruleEngineCallServiceMock;
@Mock
private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock;
@Mock @Mock
private TbCallback tbCallbackMock; private TbCallback tbCallbackMock;
@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest {
then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock);
} }
@Test
public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() {
// GIVEN
ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock);
var requestId = UUID.randomUUID();
// error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
// ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
.setRequestIdMSB(requestId.getMostSignificantBits())
.setRequestIdLSB(requestId.getLeastSignificantBits())
.setError(RpcError.NOT_FOUND.ordinal())
.build();
doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock);
// WHEN
defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock);
// THEN
var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture());
var response = responseCaptor.getValue();
assertThat(response.getId()).isEqualTo(requestId);
assertThat(response.getError()).contains(RpcError.NOT_FOUND);
assertThat(response.getResponse()).isEmpty();
then(tbCallbackMock).should().onSuccess();
}
} }

78
application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java

@ -0,0 +1,78 @@
/**
* 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.queue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.rpc.RpcError;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.doCallRealMethod;
@ExtendWith(MockitoExtension.class)
public class DefaultTbRuleEngineConsumerServiceTest {
@Mock
private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock;
@Mock
private TbCallback tbCallbackMock;
@Mock
private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock;
@Test
public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() {
// GIVEN
ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock);
var requestId = UUID.randomUUID();
// error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination
// ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to "").
var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder()
.setRequestIdMSB(requestId.getMostSignificantBits())
.setRequestIdLSB(requestId.getLeastSignificantBits())
.setError(RpcError.NOT_FOUND.ordinal())
.build();
var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build();
var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg);
doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock);
// WHEN
defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock);
// THEN
var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class);
then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture());
var response = responseCaptor.getValue();
assertThat(response.getId()).isEqualTo(requestId);
assertThat(response.getError()).contains(RpcError.NOT_FOUND);
assertThat(response.getResponse()).isEmpty();
then(tbCallbackMock).should().onSuccess();
}
}

2
common/actor/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/cache/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/cluster-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/coap-server/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

62
common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java

@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
dtlsSessionsExecutor.shutdownNow(); dtlsSessionsExecutor.shutdownNow();
} }
log.info("Stopping CoAP server!"); log.info("Stopping CoAP server!");
server.destroy(); if (server != null) {
server.destroy();
}
log.info("CoAP server stopped!"); log.info("CoAP server stopped!");
} }
@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial
private CoapServer createCoapServer() throws UnknownHostException { private CoapServer createCoapServer() throws UnknownHostException {
Configuration networkConfig = createNetworkConfiguration(); Configuration networkConfig = createNetworkConfiguration();
server = new CoapServer(networkConfig); try {
server = new CoapServer(networkConfig);
CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder();
InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); InetAddress addr = InetAddress.getByName(coapServerContext.getHost());
InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort());
noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr);
noSecCoapEndpointBuilder.setConfiguration(networkConfig);
CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build();
server.addEndpoint(noSecCoapEndpoint);
if (isDtlsEnabled()) {
createDtlsEndpoint(networkConfig);
dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName());
dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS);
}
Resource root = server.getRoot();
TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
server.setMessageDeliverer(messageDeliverer);
noSecCoapEndpointBuilder.setConfiguration(networkConfig); server.start();
CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); return server;
server.addEndpoint(noSecCoapEndpoint); } catch (RuntimeException | UnknownHostException e) {
if (isDtlsEnabled()) { log.error("Failed to start CoAP server, releasing resources", e);
createDtlsEndpoint(networkConfig); try {
dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); if (dtlsSessionsExecutor != null) {
dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); dtlsSessionsExecutor.shutdownNow();
}
if (server != null) {
server.destroy();
}
} catch (Exception suppressed) {
e.addSuppressed(suppressed);
} finally {
server = null;
dtlsSessionsExecutor = null;
dtlsConnector = null;
dtlsCoapEndpoint = null;
tbDtlsCertificateVerifier = null;
}
throw e;
} }
Resource root = server.getRoot();
TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root);
server.setMessageDeliverer(messageDeliverer);
server.start();
return server;
} }
private boolean isDtlsEnabled() { private boolean isDtlsEnabled() {

145
common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java

@ -0,0 +1,145 @@
/**
* 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.server.resources.Resource;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.junit.jupiter.api.AfterEach;
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.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.ThingsBoardExecutors;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.concurrent.ScheduledExecutorService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class DefaultCoapServerServiceTest {
private static final String HOST = "127.0.0.1";
@Mock
private CoapServerContext mockCoapServerContext;
private DefaultCoapServerService service;
private DatagramSocket occupiedSocket;
private int occupiedPort;
@BeforeEach
public void setUp() throws Exception {
occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
occupiedPort = occupiedSocket.getLocalPort();
service = new DefaultCoapServerService();
ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext);
when(mockCoapServerContext.getHost()).thenReturn(HOST);
when(mockCoapServerContext.getPort()).thenReturn(occupiedPort);
when(mockCoapServerContext.getDtlsSettings()).thenReturn(null);
}
@AfterEach
public void tearDown() {
if (occupiedSocket != null && !occupiedSocket.isClosed()) {
occupiedSocket.close();
}
}
@Test
public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() {
assertThatThrownBy(() -> service.init())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("None of the server endpoints could be started");
assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
}
@Test
public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception {
// DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start().
// This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not.
TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class);
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings);
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class);
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1));
TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class);
when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L);
when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier);
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig);
ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class);
Resource mockRoot = mock(Resource.class);
try (MockedStatic<ThingsBoardExecutors> executorsStatic = mockStatic(ThingsBoardExecutors.class);
MockedConstruction<CoapServer> serverMock = mockConstruction(CoapServer.class, (server, ctx) -> {
when(server.getRoot()).thenReturn(mockRoot);
doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start();
});
MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class);
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> {
when(builder.setInetSocketAddress(any())).thenReturn(builder);
when(builder.setConfiguration(any())).thenReturn(builder);
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder);
when(builder.build()).thenReturn(mock(CoapEndpoint.class));
})) {
executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor);
assertThatThrownBy(() -> service.init())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("None of the server endpoints could be started");
// DTLS branch was actually entered and the executor was created...
verify(mockDtlsSettings).dtlsConnectorConfig(any());
// ...and the cleanup branch shut it down and destroyed the server.
verify(mockExecutor).shutdownNow();
verify(serverMock.constructed().get(0)).destroy();
}
assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull();
assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull();
assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull();
}
}

2
common/dao-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/data/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

7
common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java

@ -26,6 +26,7 @@ import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.function.Function; import java.util.function.Function;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringUtils.repeat; import static org.apache.commons.lang3.StringUtils.repeat;
@ -39,6 +40,12 @@ public class StringUtils {
public static final int INDEX_NOT_FOUND = -1; public static final int INDEX_NOT_FOUND = -1;
public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]");
public static boolean containsControlChars(String source) {
return source != null && CONTROL_CHARS.matcher(source).find();
}
public static boolean isEmpty(String source) { public static boolean isEmpty(String source) {
return source == null || source.isEmpty(); return source == null || source.isEmpty();
} }

2
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -45,4 +45,6 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF; long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings; TrendzSettings trendzSettings;
String nullsOrderStrategy;
boolean edqsEnabled;
} }

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java

@ -42,6 +42,8 @@ public sealed interface AiChatModelConfig<C extends AiChatModelConfig<C>> extend
C withMaxRetries(Integer maxRetries); C withMaxRetries(Integer maxRetries);
boolean supportsJsonMode(); boolean supportsSchemalessJsonOutput();
boolean supportsJsonSchemaOutput();
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java

@ -52,8 +52,13 @@ public record AmazonBedrockChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return false; return false;
} }
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java

@ -53,8 +53,13 @@ public record AnthropicChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return false; return false;
} }
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java

@ -54,7 +54,12 @@ public record AzureOpenAiChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

9
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java

@ -54,8 +54,13 @@ public record GitHubModelsChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return false; return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
} }
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java

@ -55,7 +55,12 @@ public record GoogleAiGeminiChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java

@ -55,7 +55,12 @@ public record GoogleVertexAiGeminiChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java

@ -54,7 +54,12 @@ public record MistralAiChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java

@ -54,7 +54,12 @@ public record OllamaChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java

@ -54,7 +54,12 @@ public record OpenAiChatModelConfig(
} }
@Override @Override
public boolean supportsJsonMode() { public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true; return true;
} }

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java

@ -24,7 +24,7 @@ public enum AlarmCommentSubType {
ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"), ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"),
UNASSIGNED_BY_USER("Alarm was unassigned by user %s"), UNASSIGNED_BY_USER("Alarm was unassigned by user %s"),
UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"), UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"),
COMMENT_DELETED("User %s deleted his comment"), COMMENT_DELETED("Comment was deleted by user %s"),
SEVERITY_CHANGED("Alarm severity was updated from %s to %s"); SEVERITY_CHANGED("Alarm severity was updated from %s to %s");
@Getter @Getter

12
common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java

@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc;
*/ */
public enum RpcError { public enum RpcError {
NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL; NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL;
private static final RpcError[] VALUES = values();
/**
* Resolves an {@link RpcError} from the proto {@code error} ordinal.
* Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals
* that a newer node in a mixed-version cluster might emit, so callers never hit an
* {@link ArrayIndexOutOfBoundsException}.
*/
public static RpcError fromProtoErrorCode(int errorCode) {
return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null;
}
} }

2
common/discovery-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/edge-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/edge-api/src/main/proto/edge.proto

@ -49,10 +49,12 @@ enum EdgeVersion {
V_4_2_2 = 4220; V_4_2_2 = 4220;
V_4_2_2_1 = 4221; V_4_2_2_1 = 4221;
V_4_2_2_2 = 4222; V_4_2_2_2 = 4222;
V_4_2_2_3 = 4223;
V_4_3_0_1 = 15; V_4_3_0_1 = 15;
V_4_3_1 = 4310; V_4_3_1 = 4310;
V_4_3_1_1 = 4311; V_4_3_1_1 = 4311;
V_4_3_1_2 = 4312; V_4_3_1_2 = 4312;
V_4_3_1_3 = 4313;
V_LATEST = 99999; V_LATEST = 99999;
} }

2
common/edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/message/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>common</artifactId> <artifactId>common</artifactId>

2
common/proto/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

7
common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java

@ -585,10 +585,11 @@ public class ProtoUtils {
} }
private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) {
TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse();
FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse(
new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()),
proto.getRpcResponse().getResponse(), rpcResponse.hasResponse() ? rpcResponse.getResponse() : null,
proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); RpcError.fromProtoErrorCode(rpcResponse.getError()));
return new FromDeviceRpcResponseActorMsg( return new FromDeviceRpcResponseActorMsg(
proto.getRequestId(), proto.getRequestId(),
TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())),

2
common/proto/src/main/proto/queue.proto

@ -1273,7 +1273,7 @@ message LocalSubscriptionServiceMsgProto {
message FromDeviceRPCResponseProto { message FromDeviceRPCResponseProto {
int64 requestIdMSB = 1; int64 requestIdMSB = 1;
int64 requestIdLSB = 2; int64 requestIdLSB = 2;
string response = 3; optional string response = 3;
int32 error = 4; int32 error = 4;
} }

11
common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java

@ -228,6 +228,17 @@ class ProtoUtilsTest {
assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg);
} }
@Test
void protoFromDeviceRpcResponseOnewaySerialization() {
// Oneway RPC success: response and error are both null. Relies on the proto
// 'optional string response' presence bit so the receiver round-trips null
// rather than seeing the proto3 default "".
FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null));
TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg);
Assertions.assertNotNull(serializedMsg);
assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg);
}
@Test @Test
void protoRemoveRpcActorSerialization() { void protoRemoveRpcActorSerialization() {
RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id);

2
common/queue/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/script/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/script/remote-js-client/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>script</artifactId> <artifactId>script</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.script</groupId> <groupId>org.thingsboard.common.script</groupId>

2
common/script/script-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>script</artifactId> <artifactId>script</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.script</groupId> <groupId>org.thingsboard.common.script</groupId>

2
common/stats/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/transport/coap/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

2
common/transport/http/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

2
common/transport/lwm2m/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

25
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java

@ -82,13 +82,32 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto
@PostConstruct @PostConstruct
public void init() { public void init() {
log.info("Starting LwM2M transport bootstrap server..."); log.info("Starting LwM2M transport bootstrap server...");
this.server = getLhBootstrapServer(); LeshanBootstrapServer bootstrapServer = null;
this.server.start(); try {
log.info("Started LwM2M transport bootstrap server."); bootstrapServer = getLhBootstrapServer();
this.server = bootstrapServer;
bootstrapServer.start();
log.info("Started LwM2M transport bootstrap server.");
} catch (RuntimeException e) {
log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e);
try {
if (bootstrapServer != null) {
bootstrapServer.destroy();
}
} catch (Exception suppressed) {
e.addSuppressed(suppressed);
} finally {
this.server = null;
}
throw e;
}
} }
@PreDestroy @PreDestroy
public void shutdown() { public void shutdown() {
if (server == null) {
return;
}
try { try {
log.info("Stopping LwM2M transport bootstrap server!"); log.info("Stopping LwM2M transport bootstrap server!");
server.destroy(); server.destroy();

115
common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java

@ -0,0 +1,115 @@
/**
* 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.transport.lwm2m.bootstrap;
import org.junit.jupiter.api.AfterEach;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier;
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore;
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore;
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig;
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class LwM2MTransportBootstrapServiceTest {
private static final String HOST = "127.0.0.1";
@Mock
private LwM2MTransportServerConfig serverConfig;
@Mock
private LwM2MTransportBootstrapConfig bootstrapConfig;
@Mock
private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore;
@Mock
private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore;
@Mock
private TransportService transportService;
@Mock
private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier;
private LwM2MTransportBootstrapService service;
private DatagramSocket occupiedPlain;
private DatagramSocket occupiedSecure;
@BeforeEach
public void setUp() throws Exception {
occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0));
when(bootstrapConfig.getHost()).thenReturn(HOST);
when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort());
when(bootstrapConfig.getSecureHost()).thenReturn(HOST);
when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort());
when(bootstrapConfig.getSslCredentials()).thenReturn(null);
when(serverConfig.isRecommendedCiphers()).thenReturn(false);
when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false);
when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000);
when(serverConfig.getDtlsCidLength()).thenReturn(null);
service = new LwM2MTransportBootstrapService(
serverConfig,
bootstrapConfig,
lwM2MBootstrapSecurityStore,
lwM2MInMemoryBootstrapConfigStore,
transportService,
certificateVerifier
);
}
@AfterEach
public void tearDown() {
if (occupiedPlain != null && !occupiedPlain.isClosed()) {
occupiedPlain.close();
}
if (occupiedSecure != null && !occupiedSecure.isClosed()) {
occupiedSecure.close();
}
}
@Test
public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() {
assertThatThrownBy(() -> service.init())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("None of the server endpoints could be started");
assertThat(ReflectionTestUtils.getField(service, "server")).isNull();
}
}

2
common/transport/mqtt/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

2
common/transport/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
common/transport/snmp/pom.xml

@ -21,7 +21,7 @@
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>

2
common/transport/transport-api/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common.transport</groupId> <groupId>org.thingsboard.common.transport</groupId>

115
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java

@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override @Override
public void update(TenantProfileUpdateResult update) { public void update(TenantProfileUpdateResult update) {
log.info("Received tenant profile update: {}", update.getProfile()); TenantProfile profile = update.getProfile();
EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); log.info("Received tenant profile update: {}", profile);
EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS);
EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS);
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS); EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS);
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS);
for (TenantId tenantId : update.getAffectedTenants()) { for (TenantId tenantId : update.getAffectedTenants()) {
update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype);
} }
@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
@Override @Override
public void update(TenantId tenantId) { public void update(TenantId tenantId) {
EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); TenantProfile profile = tenantProfileCache.get(tenantId);
EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); update(tenantId,
EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); createRateLimits(profile, TENANT_LIMITS),
EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); createRateLimits(profile, DEVICE_LIMITS),
update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); createRateLimits(profile, GATEWAY_LIMITS),
createRateLimits(profile, GATEWAY_DEVICE_LIMITS)
);
} }
private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype, private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype,
@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
BiConsumer<T, EntityTransportRateLimits> putFunction) { BiConsumer<T, EntityTransportRateLimits> putFunction) {
EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId); EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId);
if (oldRateLimits == null) { if (oldRateLimits == null) {
if (EntityType.TENANT.equals(entityId.getEntityType())) { logLimits(entityId, "New", newRateLimits);
log.info("[{}] New rate limits: {}", entityId, newRateLimits);
} else {
log.debug("[{}] New rate limits: {}", entityId, newRateLimits);
}
putFunction.accept(entityId, newRateLimits); putFunction.accept(entityId, newRateLimits);
} else { } else {
EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits); EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits);
if (updated != null) { if (updated != null) {
if (EntityType.TENANT.equals(entityId.getEntityType())) { logLimits(entityId, "Updated", updated);
log.info("[{}] Updated rate limits: {}", entityId, updated);
} else {
log.debug("[{}] Updated rate limits: {}", entityId, updated);
}
putFunction.accept(entityId, updated); putFunction.accept(entityId, updated);
} }
} }
} }
private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) {
// Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG.
if (EntityType.TENANT.equals(entityId.getEntityType())) {
log.info("[{}] {} rate limits: {}", entityId, action, limits);
} else {
log.debug("[{}] {} rate limits: {}", entityId, action, limits);
}
}
private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) { private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) {
boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration()); boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration());
boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration()); boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration());
@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration();
if (profile == null) { if (profile == null) {
return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW);
} else {
TransportRateLimit regularMsgRateLimit;
TransportRateLimit telemetryMsgRateLimit;
TransportRateLimit telemetryDpRateLimit;
switch (limitsType) {
case TENANT_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit());
}
case DEVICE_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit());
}
case GATEWAY_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit());
}
case GATEWAY_DEVICE_LIMITS -> {
regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit());
telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit());
telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit());
}
default -> throw new IllegalStateException("Unknown limits type: " + limitsType);
}
return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit);
} }
return new EntityTransportRateLimits(
newLimit(limitsType.getRegularMsgRateLimit().apply(profile)),
newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)),
newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile))
);
} }
private static TransportRateLimit newLimit(String config) { private static TransportRateLimit newLimit(String config) {
@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi
} }
private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) {
return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null);
} }
private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) {
return perDeviceLimits.computeIfAbsent(deviceId, k -> { return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId));
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS);
getTenantDevices(tenantId).add(deviceId);
return limits;
});
} }
private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) { private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) {
return perGatewayLimits.computeIfAbsent(gatewayId, k -> { return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId));
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS);
getTenantGateways(tenantId).add(gatewayId);
return limits;
});
} }
private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) { private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) {
return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> { return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId));
EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); }
getTenantGatewayDevices(tenantId).add(gatewayId);
return limits; private <T extends EntityId> EntityTransportRateLimits getRateLimits(ConcurrentMap<T, EntityTransportRateLimits> limitsMap, TenantId tenantId,
}); T entityId, TransportLimitsType limitsType, Runnable onMiss) {
EntityTransportRateLimits limits = limitsMap.get(entityId);
if (limits == null) {
// Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may
// block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function.
TenantProfile tenantProfile = tenantProfileCache.get(tenantId);
limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType));
// Runs on every observed miss, including callers that lost the computeIfAbsent race and got an
// existing value back - NOT only on actual creation, so the callback must be idempotent.
if (onMiss != null) {
onMiss.run();
}
}
return limits;
} }
private Set<DeviceId> getTenantDevices(TenantId tenantId) { private Set<DeviceId> getTenantDevices(TenantId tenantId) {

35
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java

@ -15,6 +15,39 @@
*/ */
package org.thingsboard.server.common.transport.limits; package org.thingsboard.server.common.transport.limits;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import java.util.function.Function;
@Getter
@RequiredArgsConstructor
public enum TransportLimitsType { public enum TransportLimitsType {
TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS
TENANT_LIMITS(
DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit
),
DEVICE_LIMITS(
DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit
),
GATEWAY_LIMITS(
DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit
),
GATEWAY_DEVICE_LIMITS(
DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit,
DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit
);
private final Function<DefaultTenantProfileConfiguration, String> regularMsgRateLimit;
private final Function<DefaultTenantProfileConfiguration, String> telemetryMsgRateLimit;
private final Function<DefaultTenantProfileConfiguration, String> telemetryDataPointsRateLimit;
} }

74
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java

@ -27,7 +27,6 @@ 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.SslCredentialsConfig;
import org.thingsboard.server.queue.util.TbTransportComponent; import org.thingsboard.server.queue.util.TbTransportComponent;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -165,7 +164,6 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
static class CertificateWatcher { static class CertificateWatcher {
private final List<Path> paths; private final List<Path> paths;
private final Runnable reloadCallback; private final Runnable reloadCallback;
private final Map<Path, Long> lastModifiedMap;
private final Map<Path, String> lastChecksumMap; private final Map<Path, String> lastChecksumMap;
private int consecutiveFailures; private int consecutiveFailures;
private String failedCombinedChecksum; private String failedCombinedChecksum;
@ -173,60 +171,23 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
CertificateWatcher(List<Path> paths, Runnable reloadCallback) { CertificateWatcher(List<Path> paths, Runnable reloadCallback) {
this.paths = paths; this.paths = paths;
this.reloadCallback = reloadCallback; this.reloadCallback = reloadCallback;
this.lastModifiedMap = new HashMap<>();
this.lastChecksumMap = new HashMap<>(); this.lastChecksumMap = new HashMap<>();
for (Path path : paths) { for (Path path : paths) {
lastModifiedMap.put(path, getLastModifiedTime(path));
lastChecksumMap.put(path, calculateChecksum(path)); lastChecksumMap.put(path, calculateChecksum(path));
} }
this.consecutiveFailures = 0; this.consecutiveFailures = 0;
} }
synchronized void checkAndReload(String name) { synchronized void checkAndReload(String name) {
boolean anyModifiedChanged = false;
for (Path path : paths) {
long currentModified = getLastModifiedTime(path);
Long lastModified = lastModifiedMap.getOrDefault(path, 0L);
if (currentModified != lastModified) {
anyModifiedChanged = true;
break;
}
}
if (!anyModifiedChanged) {
return;
}
// Capture mtimes and checksums together before the callback runs.
// Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll.
Map<Path, Long> currentModifiedTimes = new HashMap<>();
Map<Path, String> currentChecksums = new HashMap<>(); Map<Path, String> currentChecksums = new HashMap<>();
StringBuilder combined = new StringBuilder();
for (Path path : paths) { for (Path path : paths) {
currentModifiedTimes.put(path, getLastModifiedTime(path)); currentChecksums.put(path, calculateChecksum(path));
String checksum = calculateChecksum(path);
currentChecksums.put(path, checksum);
if (!combined.isEmpty()) {
combined.append("|");
}
combined.append(path).append("=").append(checksum);
} }
String combinedChecksum = combined.toString(); String combinedChecksum = combinedChecksum(currentChecksums);
String oldCombinedChecksum = combinedChecksum(lastChecksumMap);
// Build old combined checksum for comparison
StringBuilder oldCombined = new StringBuilder();
for (Path path : paths) {
if (!oldCombined.isEmpty()) {
oldCombined.append("|");
}
oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, ""));
}
String oldCombinedChecksum = oldCombined.toString();
if (combinedChecksum.equals(oldCombinedChecksum)) { if (combinedChecksum.equals(oldCombinedChecksum)) {
// Content unchanged, just update modification times // Content unchanged
for (Path path : paths) {
lastModifiedMap.put(path, currentModifiedTimes.get(path));
}
return; return;
} }
@ -237,41 +198,34 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis
} }
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
// Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle
for (Path path : paths) {
lastModifiedMap.put(path, currentModifiedTimes.get(path));
}
return; return;
} }
try { try {
log.info("Certificate change detected for: {}. Triggering reload...", name); log.info("Certificate change detected for: {}. Triggering reload...", name);
reloadCallback.run(); reloadCallback.run();
for (Path path : paths) { lastChecksumMap.putAll(currentChecksums);
lastModifiedMap.put(path, currentModifiedTimes.get(path));
lastChecksumMap.put(path, currentChecksums.get(path));
}
consecutiveFailures = 0; consecutiveFailures = 0;
failedCombinedChecksum = null; failedCombinedChecksum = null;
} catch (Exception e) { } catch (Exception e) {
consecutiveFailures++; consecutiveFailures++;
failedCombinedChecksum = combinedChecksum; failedCombinedChecksum = combinedChecksum;
// Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries // Deliberately NOT updating lastChecksumMap here, so the next poll cycle still sees a differing
// (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). // checksum, re-enters this method, and retries the same content.
log.error("Failed to reload certificate for {} (attempt {}/{}): {}", log.error("Failed to reload certificate for {} (attempt {}/{}): {}",
name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e);
} }
} }
private long getLastModifiedTime(Path path) { private String combinedChecksum(Map<Path, String> checksums) {
try { StringBuilder combined = new StringBuilder();
if (!Files.exists(path)) { for (Path path : paths) {
return 0; if (!combined.isEmpty()) {
combined.append("|");
} }
return Files.getLastModifiedTime(path).toMillis(); combined.append(path).append("=").append(checksums.getOrDefault(path, ""));
} catch (IOException e) {
return 0;
} }
return combined.toString();
} }
private String calculateChecksum(Path path) { private String calculateChecksum(Path path) {

4
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java

@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements
private int notificationsPollDuration; private int notificationsPollDuration;
@Value("${transport.stats.enabled:false}") @Value("${transport.stats.enabled:false}")
private boolean statsEnabled; private boolean statsEnabled;
@Value("${transport.callback_thread_pool_size:20}")
private int callbackThreadPoolSize;
@Autowired @Autowired
@Lazy @Lazy
@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements
this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer"); this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer");
this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer"); this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer");
this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer"); this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer");
this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass());
this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS); this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS);
transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate(); transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate();
transportApiRequestTemplate.setMessagesStats(transportApiStats); transportApiRequestTemplate.setMessagesStats(transportApiStats);

76
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.common.transport.service; package org.thingsboard.server.common.transport.service;
import com.google.common.util.concurrent.Striped;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
@ -37,14 +38,20 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Component @Component
@TbTransportComponent @TbTransportComponent
@Slf4j @Slf4j
public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache {
private final Lock tenantProfileFetchLock = new ReentrantLock(); // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache
// misses (cached tenants never take the lock), and concurrent fetches are already bounded by the
// transport callback pool, so this comfortably over-provisions the realistic concurrency.
private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024;
// Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while
// letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size).
private final Striped<Lock> tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES);
private final ConcurrentMap<TenantProfileId, TenantProfile> profiles = new ConcurrentHashMap<>(); private final ConcurrentMap<TenantProfileId, TenantProfile> profiles = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantId, TenantProfileId> tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap<TenantId, TenantProfileId> tenantIds = new ConcurrentHashMap<>();
private final ConcurrentMap<TenantProfileId, Set<TenantId>> tenantProfileIds = new ConcurrentHashMap<>(); private final ConcurrentMap<TenantProfileId, Set<TenantId>> tenantProfileIds = new ConcurrentHashMap<>();
@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil
} }
private TenantProfile getTenantProfile(TenantId tenantId) { private TenantProfile getTenantProfile(TenantId tenantId) {
TenantProfile profile = null; TenantProfile profile = lookupCached(tenantId);
TenantProfileId tenantProfileId = tenantIds.get(tenantId);
if (tenantProfileId != null) {
profile = profiles.get(tenantProfileId);
}
if (profile == null) { if (profile == null) {
tenantProfileFetchLock.lock(); // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing
// different tenants to resolve their profiles concurrently.
Lock lock = tenantProfileFetchLocks.get(tenantId);
lock.lock();
try { try {
tenantProfileId = tenantIds.get(tenantId); profile = lookupCached(tenantId);
if (tenantProfileId != null) {
profile = profiles.get(tenantProfileId);
}
if (profile == null) { if (profile == null) {
TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() profile = fetchAndCacheTenantProfile(tenantId);
.setEntityType(EntityType.TENANT.name())
.setEntityIdMSB(tenantId.getId().getMostSignificantBits())
.setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
.build();
TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
TenantProfile existingProfile = profiles.get(profile.getId());
if (existingProfile != null) {
profile = existingProfile;
} else {
profiles.put(profile.getId(), profile);
}
tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
tenantIds.put(tenantId, profile.getId());
ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
} }
} finally { } finally {
tenantProfileFetchLock.unlock(); lock.unlock();
} }
} }
return profile; return profile;
} }
private TenantProfile lookupCached(TenantId tenantId) {
TenantProfileId tenantProfileId = tenantIds.get(tenantId);
if (tenantProfileId != null) {
return profiles.get(tenantProfileId);
}
return null;
}
private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) {
TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder()
.setEntityType(EntityType.TENANT.name())
.setEntityIdMSB(tenantId.getId().getMostSignificantBits())
.setEntityIdLSB(tenantId.getId().getLeastSignificantBits())
.build();
TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg);
TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile());
TenantProfile existingProfile = profiles.get(profile.getId());
if (existingProfile != null) {
profile = existingProfile;
} else {
profiles.put(profile.getId(), profile);
}
tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId);
tenantIds.put(tenantId, profile.getId());
ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState());
rateLimitService.update(tenantId, apiUsageState.isTransportEnabled());
return profile;
}
} }

202
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java

@ -0,0 +1,202 @@
/**
* 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.transport.limits;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.transport.TransportTenantProfileCache;
import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DefaultTransportRateLimitServiceTest {
private TransportTenantProfileCache tenantProfileCache;
private ExecutorService executor;
private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID());
@BeforeEach
void setUp() {
tenantProfileCache = mock(TransportTenantProfileCache.class);
executor = Executors.newCachedThreadPool();
}
@AfterEach
void tearDown() {
executor.shutdownNow();
}
@Test
void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception {
// Two concurrent rate-limit checks for the SAME tenant must both be able to reach
// the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside
// ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation
// node and never reaches the fetch -> the latch never reaches zero.
CountDownLatch bothCallersReachedFetch = new CountDownLatch(2);
CountDownLatch releaseFetch = new CountDownLatch(1);
when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> {
bothCallersReachedFetch.countDown();
releaseFetch.await(5, TimeUnit.SECONDS);
return tenantProfile();
});
DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
Runnable check = () -> service.checkLimits(tenant, null, null, 1, false);
executor.submit(check);
executor.submit(check);
boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS);
releaseFetch.countDown();
assertThat(bothReached)
.as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)")
.isTrue();
}
@ParameterizedTest
@EnumSource(TransportLimitsType.class)
void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) {
// Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS
// wired to the plain gateway getters) resolves to the wrong value and fails the assertion.
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
config.setTransportTenantMsgRateLimit("tenant-msg");
config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg");
config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp");
config.setTransportDeviceMsgRateLimit("device-msg");
config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg");
config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp");
config.setTransportGatewayMsgRateLimit("gateway-msg");
config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg");
config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp");
config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg");
config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg");
config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp");
String prefix = switch (type) {
case TENANT_LIMITS -> "tenant";
case DEVICE_LIMITS -> "device";
case GATEWAY_LIMITS -> "gateway";
case GATEWAY_DEVICE_LIMITS -> "gateway-device";
};
assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg");
assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg");
assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp");
}
@ParameterizedTest
@EnumSource(EntityLevel.class)
void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) {
DeviceId entity = new DeviceId(UUID.randomUUID());
when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600"));
DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache);
// First check resolves the (permissive) limit and must register the entity into the per-tenant
// tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it.
assertThat(level.check(service, tenant, entity))
.as("permissive limit should allow the first %s check", level).isNull();
// Tighten the limit to a single message and push a profile update for this tenant.
service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant)));
// The freshly merged "1:600" bucket allows exactly one message...
assertThat(level.check(service, tenant, entity)).isNull();
// ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity.
assertThat(level.check(service, tenant, entity))
.as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull();
}
private TenantProfile tenantProfile() {
return profileWith(new DefaultTenantProfileConfiguration());
}
private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) {
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
level.setRegularMsgRateLimit(config, regularMsgRateLimit);
return profileWith(config);
}
private TenantProfile profileWith(DefaultTenantProfileConfiguration config) {
TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
profile.setName("test-profile");
TenantProfileData profileData = new TenantProfileData();
profileData.setConfiguration(config);
profile.setProfileData(profileData);
return profile;
}
private enum EntityLevel {
DEVICE {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportDeviceMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, null, entityId, 0, false);
}
},
GATEWAY {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportGatewayMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, entityId, null, 0, false);
}
},
GATEWAY_DEVICE {
@Override
void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) {
config.setTransportGatewayDeviceMsgRateLimit(value);
}
@Override
Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) {
return service.checkLimits(tenantId, null, entityId, 0, true);
}
};
abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value);
abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId);
}
}

64
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java

@ -31,10 +31,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
public class CertificateReloadManagerTest { public class CertificateReloadManagerTest {
@ -59,11 +56,10 @@ public class CertificateReloadManagerTest {
} }
} }
private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { private void writeFileAndBumpMtime(Path path, String content, long baselineMtime) throws IOException {
Files.writeString(path, content); Files.writeString(path, content);
await().atMost(2, SECONDS) // Force a strictly newer mtime: back-to-back writes can share a millisecond, hiding the change from the watcher.
.pollInterval(10, MILLISECONDS) Files.setLastModifiedTime(path, FileTime.fromMillis(baselineMtime + 1000));
.until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime);
} }
private long mtime(Path path) throws IOException { private long mtime(Path path) throws IOException {
@ -77,7 +73,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -85,7 +81,7 @@ public class CertificateReloadManagerTest {
} }
@Test @Test
public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() {
AtomicInteger reloadCount = new AtomicInteger(0); AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
@ -147,7 +143,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet);
long baseline = mtime(keyFile); long baseline = mtime(keyFile);
writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); writeFileAndBumpMtime(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -168,8 +164,8 @@ public class CertificateReloadManagerTest {
long baseline1 = mtime(certFile); long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File); long baseline2 = mtime(cert2File);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -191,8 +187,8 @@ public class CertificateReloadManagerTest {
long baseline1 = mtime(certFile); long baseline1 = mtime(certFile);
long baseline2 = mtime(cert2File); long baseline2 = mtime(cert2File);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1);
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -224,9 +220,7 @@ public class CertificateReloadManagerTest {
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n");
} }
await().atMost(2, SECONDS) Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline + 1000));
.pollInterval(10, MILLISECONDS)
.until(() -> mtime(certFile) != baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -242,7 +236,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
new Thread(() -> { new Thread(() -> {
@ -272,7 +266,7 @@ public class CertificateReloadManagerTest {
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); writeFileAndBumpMtime(certFile, originalContent, baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -289,7 +283,7 @@ public class CertificateReloadManagerTest {
}); });
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) { for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -311,19 +305,41 @@ public class CertificateReloadManagerTest {
}); });
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(1); assertThat(reloadAttempts.get()).isEqualTo(1);
shouldFail.set(0); shouldFail.set(0);
long baseline2 = mtime(certFile); long baseline2 = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(2); assertThat(reloadAttempts.get()).isEqualTo(2);
} }
@Test
public void givenContentChangedButMtimeUnchanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception {
// Bug fingerprint: a cert-manager rotation that lands in the same wall-clock millisecond as the
// watcher's recorded baseline mtime. Files.getLastModifiedTime().toMillis() truncates to the ms,
// so the rotated content shares the baseline mtime and an mtime-only gate would never re-hash it.
AtomicInteger reloadCount = new AtomicInteger(0);
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet);
long baseline = mtime(certFile);
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nROTATED_SAME_MS\n-----END CERTIFICATE-----\n");
// Force the mtime back to the exact baseline millisecond — content changed, timestamp did not.
Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline));
// Sanity guard: the watcher observes a timestamp identical to its recorded baseline.
assertThat(mtime(certFile)).isEqualTo(baseline);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadCount.get()).isEqualTo(1);
}
@Test @Test
public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception {
AtomicInteger reloadAttempts = new AtomicInteger(0); AtomicInteger reloadAttempts = new AtomicInteger(0);
@ -337,7 +353,7 @@ public class CertificateReloadManagerTest {
}); });
long baseline = mtime(certFile); long baseline = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline);
for (int i = 0; i < 15; i++) { for (int i = 0; i < 15; i++) {
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
@ -346,7 +362,7 @@ public class CertificateReloadManagerTest {
shouldFail.set(0); shouldFail.set(0);
long baseline2 = mtime(certFile); long baseline2 = mtime(certFile);
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2);
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates");
assertThat(reloadAttempts.get()).isEqualTo(11); assertThat(reloadAttempts.get()).isEqualTo(11);

191
common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java

@ -0,0 +1,191 @@
/**
* 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.transport.service;
import com.google.common.util.concurrent.Striped;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.ApiUsageState;
import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.transport.TransportService;
import org.thingsboard.server.common.transport.limits.TransportRateLimitService;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg;
import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DefaultTransportTenantProfileCacheTest {
private DefaultTransportTenantProfileCache cache;
private TransportService transportService;
private TransportRateLimitService rateLimitService;
private ExecutorService executor;
// Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES.
private static final int STRIPE_COUNT = 1024;
private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID());
// Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant
// test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe.
private final TenantId tenantB = differentStripeFrom(tenantA);
private static TenantId differentStripeFrom(TenantId other) {
Striped<Lock> probe = Striped.lock(STRIPE_COUNT);
TenantId candidate = TenantId.fromUUID(UUID.randomUUID());
while (probe.get(candidate) == probe.get(other)) {
candidate = TenantId.fromUUID(UUID.randomUUID());
}
return candidate;
}
@BeforeEach
void setUp() {
cache = new DefaultTransportTenantProfileCache();
transportService = mock(TransportService.class);
rateLimitService = mock(TransportRateLimitService.class);
doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean());
cache.setTransportService(transportService);
cache.setRateLimitService(rateLimitService);
executor = Executors.newCachedThreadPool();
}
@AfterEach
void tearDown() {
executor.shutdownNow();
}
@Test
void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception {
CountDownLatch tenantAFetchStarted = new CountDownLatch(1);
CountDownLatch releaseTenantA = new CountDownLatch(1);
GetEntityProfileResponseMsg responseA = responseFor(tenantA);
GetEntityProfileResponseMsg responseB = responseFor(tenantB);
when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
GetEntityProfileRequestMsg msg = invocation.getArgument(0);
TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB()));
if (requested.equals(tenantA)) {
tenantAFetchStarted.countDown();
releaseTenantA.await(5, TimeUnit.SECONDS);
return responseA;
}
return responseB;
});
// T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip.
Future<TenantProfile> tenantAResult = executor.submit(() -> cache.get(tenantA));
assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS))
.as("tenantA fetch should have started").isTrue();
// T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch.
// Fails today (single global lock); passes once locking is per-tenant.
TenantProfile tenantBProfile = CompletableFuture
.supplyAsync(() -> cache.get(tenantB), executor)
.get(2, TimeUnit.SECONDS);
assertThat(tenantBProfile).isNotNull();
releaseTenantA.countDown();
assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull();
}
@Test
void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception {
// The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse
// into a single cross-service fetch (the rest are served from cache). Assert that contract directly.
int callers = 8;
CountDownLatch fetchStarted = new CountDownLatch(1);
CountDownLatch releaseFetch = new CountDownLatch(1);
when(transportService.getEntityProfile(any())).thenAnswer(invocation -> {
fetchStarted.countDown();
// Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock.
releaseFetch.await(5, TimeUnit.SECONDS);
return responseFor(tenantA);
});
CountDownLatch allSubmitted = new CountDownLatch(callers);
List<Future<TenantProfile>> results = new ArrayList<>();
for (int i = 0; i < callers; i++) {
results.add(executor.submit(() -> {
allSubmitted.countDown();
return cache.get(tenantA);
}));
}
assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue();
assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue();
releaseFetch.countDown();
for (Future<TenantProfile> result : results) {
assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull();
}
// All 8 callers resolved the same tenant, but only one of them hit the backend.
verify(transportService, times(1)).getEntityProfile(any());
}
private GetEntityProfileResponseMsg responseFor(TenantId tenantId) {
TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID()));
profile.setName("profile-" + tenantId.getId());
return GetEntityProfileResponseMsg.newBuilder()
.setEntityType(EntityType.TENANT.name())
.setTenantProfile(ProtoUtils.toProto(profile))
.setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId)))
.build();
}
private ApiUsageState enabledApiUsageState(TenantId tenantId) {
ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID()));
state.setTenantId(tenantId);
state.setEntityId(tenantId);
state.setTransportState(ApiUsageStateValue.ENABLED);
state.setDbStorageState(ApiUsageStateValue.ENABLED);
state.setReExecState(ApiUsageStateValue.ENABLED);
state.setJsExecState(ApiUsageStateValue.ENABLED);
state.setTbelExecState(ApiUsageStateValue.ENABLED);
state.setEmailExecState(ApiUsageStateValue.ENABLED);
state.setSmsExecState(ApiUsageStateValue.ENABLED);
state.setAlarmExecState(ApiUsageStateValue.ENABLED);
state.setVersion(1L);
return state;
}
}

2
common/util/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

6
common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java

@ -36,12 +36,16 @@ public class NumberUtils {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue();
} }
public static long toLong(double value) {
return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue();
}
public static Object roundResult(double value, Integer precision) { public static Object roundResult(double value, Integer precision) {
if (precision == null) { if (precision == null) {
return value; return value;
} }
if (precision.equals(0)) { if (precision.equals(0)) {
return toInt(value); return toLong(value);
} }
return toFixed(value, precision); return toFixed(value, precision);
} }

11
common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java

@ -51,11 +51,20 @@ public class NumberUtilsTest {
assertThat(NumberUtils.toInt(28.0)).isEqualTo(28); assertThat(NumberUtils.toInt(28.0)).isEqualTo(28);
} }
@Test
public void toLong() {
assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L);
assertThat(NumberUtils.toLong(12.8)).isEqualTo(13L);
assertThat(NumberUtils.toLong(28.0)).isEqualTo(28L);
assertThat(NumberUtils.toLong(3_980_173_734.0)).isEqualTo(3_980_173_734L);
}
@Test @Test
public void roundResult() { public void roundResult() {
assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729); assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729);
assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729); assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729L);
assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17); assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17);
assertThat(NumberUtils.roundResult(3_980_173_734.0, 0)).isEqualTo(3_980_173_734L);
} }
} }

2
common/version-control/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>common</artifactId> <artifactId>common</artifactId>
</parent> </parent>
<groupId>org.thingsboard.common</groupId> <groupId>org.thingsboard.common</groupId>

2
dao/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>dao</artifactId> <artifactId>dao</artifactId>

4
dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java

@ -32,6 +32,10 @@ public class DashboardDataValidator extends DataValidator<Dashboard> {
@Override @Override
protected void validateCreate(TenantId tenantId, Dashboard data) { protected void validateCreate(TenantId tenantId, Dashboard data) {
validateMaxDashboardsPerTenant(tenantId);
}
public void validateMaxDashboardsPerTenant(TenantId tenantId) {
validateNumberOfEntitiesPerTenant(tenantId, EntityType.DASHBOARD); validateNumberOfEntitiesPerTenant(tenantId, EntityType.DASHBOARD);
} }

18
dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java

@ -18,10 +18,13 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceCredentialsDao;
import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
@ -69,9 +72,24 @@ public class DeviceCredentialsDataValidator extends DataValidator<DeviceCredenti
if (StringUtils.isEmpty(deviceCredentials.getCredentialsId())) { if (StringUtils.isEmpty(deviceCredentials.getCredentialsId())) {
throw new DeviceCredentialsValidationException("Device credentials id should be specified!"); throw new DeviceCredentialsValidationException("Device credentials id should be specified!");
} }
rejectControlChars(deviceCredentials.getCredentialsId(), "credentialsId");
if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.MQTT_BASIC) {
BasicMqttCredentials mqtt = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class);
if (mqtt != null) {
rejectControlChars(mqtt.getClientId(), "clientId");
rejectControlChars(mqtt.getUserName(), "userName");
rejectControlChars(mqtt.getPassword(), "password");
}
}
Device device = deviceService.findDeviceById(tenantId, deviceCredentials.getDeviceId()); Device device = deviceService.findDeviceById(tenantId, deviceCredentials.getDeviceId());
if (device == null) { if (device == null) {
throw new DeviceCredentialsValidationException("Can't assign device credentials to non-existent device!"); throw new DeviceCredentialsValidationException("Can't assign device credentials to non-existent device!");
} }
} }
private static void rejectControlChars(String value, String fieldName) {
if (StringUtils.containsControlChars(value)) {
throw new DeviceCredentialsValidationException(fieldName + " must not contain control characters!");
}
}
} }

40
dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java

@ -51,9 +51,27 @@ public class DeviceConnectivityUtil {
public static final String COAP_IMAGE = "thingsboard/coap-clients "; public static final String COAP_IMAGE = "thingsboard/coap-clients ";
private final static Pattern VALID_URL_PATTERN = Pattern.compile("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); private final static Pattern VALID_URL_PATTERN = Pattern.compile("^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]");
private static String stripControlChars(String value) {
return value == null ? null : StringUtils.CONTROL_CHARS.matcher(value).replaceAll("_");
}
// Escapes a value that is interpolated inside a double-quoted shell argument (e.g. -u "...") in the
// publish commands shown to the operator, so it cannot break out of the quotes or trigger command/
// variable substitution. Control chars are stripped first to keep the command on a single line.
private static String escapeShellArg(String value) {
if (value == null) {
return null;
}
return stripControlChars(value)
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\\$")
.replace("`", "\\`");
}
public static String getHttpPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) { public static String getHttpPublishCommand(String protocol, String host, String port, DeviceCredentials deviceCredentials) {
return String.format("curl -v -X POST %s://%s%s/api/v1/%s/telemetry --header Content-Type:application/json --data " + JSON_EXAMPLE_PAYLOAD, return String.format("curl -v -X POST %s://%s%s/api/v1/%s/telemetry --header Content-Type:application/json --data " + JSON_EXAMPLE_PAYLOAD,
protocol, host, port, deviceCredentials.getCredentialsId()); protocol, host, port, stripControlChars(deviceCredentials.getCredentialsId()));
} }
public static String getMqttPublishCommand(String protocol, String host, String port, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) { public static String getMqttPublishCommand(String protocol, String host, String port, String deviceTelemetryTopic, DeviceCredentials deviceCredentials) {
@ -66,20 +84,20 @@ public class DeviceConnectivityUtil {
switch (deviceCredentials.getCredentialsType()) { switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN: case ACCESS_TOKEN:
command.append(" -u \"").append(deviceCredentials.getCredentialsId()).append("\""); command.append(" -u \"").append(escapeShellArg(deviceCredentials.getCredentialsId())).append("\"");
break; break;
case MQTT_BASIC: case MQTT_BASIC:
BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(),
BasicMqttCredentials.class); BasicMqttCredentials.class);
if (credentials != null) { if (credentials != null) {
if (StringUtils.isNotEmpty(credentials.getClientId())) { if (StringUtils.isNotEmpty(credentials.getClientId())) {
command.append(" -i \"").append(credentials.getClientId()).append("\""); command.append(" -i \"").append(escapeShellArg(credentials.getClientId())).append("\"");
} }
if (StringUtils.isNotEmpty(credentials.getUserName())) { if (StringUtils.isNotEmpty(credentials.getUserName())) {
command.append(" -u \"").append(credentials.getUserName()).append("\""); command.append(" -u \"").append(escapeShellArg(credentials.getUserName())).append("\"");
} }
if (StringUtils.isNotEmpty(credentials.getPassword())) { if (StringUtils.isNotEmpty(credentials.getPassword())) {
command.append(" -P \"").append(credentials.getPassword()).append("\""); command.append(" -P \"").append(escapeShellArg(credentials.getPassword())).append("\"");
} }
} else { } else {
return null; return null;
@ -117,12 +135,12 @@ public class DeviceConnectivityUtil {
dockerComposeBuilder.append("\n"); dockerComposeBuilder.append("\n");
dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" # Environment variables\n");
dockerComposeBuilder.append(" environment:\n"); dockerComposeBuilder.append(" environment:\n");
dockerComposeBuilder.append(" - TB_GW_HOST=").append(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host).append("\n"); dockerComposeBuilder.append(" - TB_GW_HOST=").append(stripControlChars(isLocalhost(host) ? HOST_DOCKER_INTERNAL : host)).append("\n");
dockerComposeBuilder.append(" - TB_GW_PORT=1883\n"); dockerComposeBuilder.append(" - TB_GW_PORT=1883\n");
switch (deviceCredentials.getCredentialsType()) { switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN: case ACCESS_TOKEN:
dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n"); dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=accessToken\n");
dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(deviceCredentials.getCredentialsId()).append("\n"); dockerComposeBuilder.append(" - TB_GW_ACCESS_TOKEN=").append(stripControlChars(deviceCredentials.getCredentialsId())).append("\n");
break; break;
case MQTT_BASIC: case MQTT_BASIC:
dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n"); dockerComposeBuilder.append(" - TB_GW_SECURITY_TYPE=usernamePassword\n");
@ -130,13 +148,13 @@ public class DeviceConnectivityUtil {
BasicMqttCredentials.class); BasicMqttCredentials.class);
if (credentials != null) { if (credentials != null) {
if (StringUtils.isNotEmpty(credentials.getClientId())) { if (StringUtils.isNotEmpty(credentials.getClientId())) {
dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(credentials.getClientId()).append("\n"); dockerComposeBuilder.append(" - TB_GW_CLIENT_ID=").append(stripControlChars(credentials.getClientId())).append("\n");
} }
if (StringUtils.isNotEmpty(credentials.getUserName())) { if (StringUtils.isNotEmpty(credentials.getUserName())) {
dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(credentials.getUserName()).append("\n"); dockerComposeBuilder.append(" - TB_GW_USERNAME=").append(stripControlChars(credentials.getUserName())).append("\n");
} }
if (StringUtils.isNotEmpty(credentials.getPassword())) { if (StringUtils.isNotEmpty(credentials.getPassword())) {
dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(credentials.getPassword()).append("\n"); dockerComposeBuilder.append(" - TB_GW_PASSWORD=").append(stripControlChars(credentials.getPassword())).append("\n");
} }
} }
break; break;
@ -201,7 +219,7 @@ public class DeviceConnectivityUtil {
String client = COAPS.equals(protocol) ? "coap-client-openssl" : "coap-client"; String client = COAPS.equals(protocol) ? "coap-client-openssl" : "coap-client";
String certificate = COAPS.equals(protocol) ? " -R " + CA_ROOT_CERT_PEM : ""; String certificate = COAPS.equals(protocol) ? " -R " + CA_ROOT_CERT_PEM : "";
return String.format("%s -v 6 -m POST%s -t \"application/json\" -e %s %s://%s%s/api/v1/%s/telemetry", return String.format("%s -v 6 -m POST%s -t \"application/json\" -e %s %s://%s%s/api/v1/%s/telemetry",
client, certificate, JSON_EXAMPLE_PAYLOAD, protocol, host, port, deviceCredentials.getCredentialsId()); client, certificate, JSON_EXAMPLE_PAYLOAD, protocol, host, port, stripControlChars(deviceCredentials.getCredentialsId()));
default: default:
return null; return null;
} }

31
dao/src/test/java/org/thingsboard/server/dao/service/validator/DashboardDataValidatorTest.java

@ -21,11 +21,19 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.exception.EntitiesLimitExceededException;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willReturn; import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -34,6 +42,8 @@ class DashboardDataValidatorTest {
@MockitoBean @MockitoBean
TenantService tenantService; TenantService tenantService;
@MockitoBean
ApiLimitService apiLimitService;
@MockitoSpyBean @MockitoSpyBean
DashboardDataValidator validator; DashboardDataValidator validator;
TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da"));
@ -53,4 +63,25 @@ class DashboardDataValidatorTest {
verify(validator).validateString("Dashboard title", dashboard.getTitle()); verify(validator).validateString("Dashboard title", dashboard.getTitle());
} }
@Test
void validateMaxDashboardsPerTenant_doesNotThrow_whenLimitNotReached() {
willReturn(true).given(apiLimitService).checkEntitiesLimit(tenantId, EntityType.DASHBOARD);
assertThatNoException().isThrownBy(() -> validator.validateMaxDashboardsPerTenant(tenantId));
}
@Test
void validateMaxDashboardsPerTenant_throwsEntitiesLimitExceeded_whenLimitReached() {
long limit = 5;
willReturn(false).given(apiLimitService).checkEntitiesLimit(tenantId, EntityType.DASHBOARD);
willReturn(limit).given(apiLimitService).getLimit(eq(tenantId), any());
assertThatThrownBy(() -> validator.validateMaxDashboardsPerTenant(tenantId))
.isInstanceOfSatisfying(EntitiesLimitExceededException.class, ex -> {
assertThat(ex.getTenantId()).isEqualTo(tenantId);
assertThat(ex.getEntityType()).isEqualTo(EntityType.DASHBOARD);
assertThat(ex.getLimit()).isEqualTo(limit);
});
}
} }

138
dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java

@ -0,0 +1,138 @@
/**
* 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.dao.service.validator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.dao.device.DeviceCredentialsDao;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.willReturn;
@ExtendWith(MockitoExtension.class)
class DeviceCredentialsDataValidatorTest {
@Mock
DeviceCredentialsDao deviceCredentialsDao;
@Mock
DeviceService deviceService;
@InjectMocks
DeviceCredentialsDataValidator validator;
final TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da"));
final DeviceId deviceId = new DeviceId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
@Test
void rejectsNewlineInAccessToken() {
DeviceCredentials creds = accessToken("safe_token\nentrypoint: [\"/bin/sh\"]");
assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
.isInstanceOf(DeviceCredentialsValidationException.class)
.hasMessageContaining("credentialsId")
.hasMessageContaining("control characters");
}
@Test
void rejectsCarriageReturnInAccessToken() {
DeviceCredentials creds = accessToken("token\rprivileged: true");
assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
.isInstanceOf(DeviceCredentialsValidationException.class)
.hasMessageContaining("control characters");
}
@Test
void rejectsNewlineInMqttClientId() {
DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd");
assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
.isInstanceOf(DeviceCredentialsValidationException.class)
.hasMessageContaining("clientId");
}
@Test
void rejectsNewlineInMqttUserName() {
DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd");
assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
.isInstanceOf(DeviceCredentialsValidationException.class)
.hasMessageContaining("userName");
}
@Test
void rejectsNewlineInMqttPassword() {
DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x");
assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds))
.isInstanceOf(DeviceCredentialsValidationException.class)
.hasMessageContaining("password");
}
@Test
void acceptsValidCredentials() {
willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId);
DeviceCredentials creds = accessToken("safe_token_123");
assertThatCode(() -> validator.validateDataImpl(tenantId, creds))
.doesNotThrowAnyException();
}
@Test
void acceptsValidMqttBasicCredentials() {
willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId);
DeviceCredentials creds = mqttBasic("client-1", "user-1", "pwd-1");
assertThatCode(() -> validator.validateDataImpl(tenantId, creds))
.doesNotThrowAnyException();
}
private DeviceCredentials accessToken(String token) {
DeviceCredentials c = new DeviceCredentials();
c.setDeviceId(deviceId);
c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN);
c.setCredentialsId(token);
return c;
}
private DeviceCredentials mqttBasic(String clientId, String userName, String password) {
BasicMqttCredentials inner = new BasicMqttCredentials();
inner.setClientId(clientId);
inner.setUserName(userName);
inner.setPassword(password);
DeviceCredentials c = new DeviceCredentials();
c.setDeviceId(deviceId);
c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC);
c.setCredentialsId("mqtt-credentials-id");
c.setCredentialsValue(JacksonUtil.toString(inner));
return c;
}
}

123
dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java

@ -16,6 +16,13 @@
package org.thingsboard.server.dao.util; package org.thingsboard.server.dao.util;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -29,4 +36,120 @@ class DeviceConnectivityUtilTest {
assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces();
} }
@Test
void validAccessTokenIsRenderedAsIs() throws Exception {
String yaml = renderCompose(accessToken("safe_token_123"));
assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n");
assertNoInjectedSiblingKeys(yaml);
}
@Test
void newlineInAccessTokenIsSanitized() throws Exception {
String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]";
String yaml = renderCompose(accessToken(malicious));
assertNoInjectedSiblingKeys(yaml);
}
@Test
void carriageReturnInAccessTokenIsSanitized() throws Exception {
String yaml = renderCompose(accessToken("token\rprivileged: true"));
assertNoInjectedSiblingKeys(yaml);
}
@Test
void newlineInMqttClientIdIsSanitized() throws Exception {
String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd"));
assertNoInjectedSiblingKeys(yaml);
}
@Test
void newlineInMqttUserNameIsSanitized() throws Exception {
String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd"));
assertNoInjectedSiblingKeys(yaml);
}
@Test
void newlineInMqttPasswordIsSanitized() throws Exception {
String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]"));
assertNoInjectedSiblingKeys(yaml);
}
@Test
void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() {
String command = DeviceConnectivityUtil.getMqttPublishCommand(
"mqtt", "localhost", "1883", "v1/devices/me/telemetry",
mqttBasic("cid", "u\";touch pwned;echo \"", "pwd"));
// the double quote must be backslash-escaped so it cannot terminate the -u "..." argument
assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\"");
assertThat(command).doesNotContain("-u \"u\";");
}
@Test
void controlCharsInMqttClientIdAreStrippedInPublishCommand() {
String command = DeviceConnectivityUtil.getMqttPublishCommand(
"mqtt", "localhost", "1883", "v1/devices/me/telemetry",
mqttBasic("c\nid", "user", "pwd"));
assertThat(command).doesNotContain("\n");
assertThat(command).contains("-i \"c_id\"");
}
@Test
void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() {
DeviceCredentials creds = accessToken("tok\nen");
assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds))
.doesNotContain("\n")
.contains("/api/v1/tok_en/telemetry");
assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds))
.doesNotContain("\n")
.contains("/api/v1/tok_en/telemetry");
}
private static String renderCompose(DeviceCredentials credentials) throws Exception {
var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile(
"host.docker.internal", "3.8-stable", credentials);
try (var in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static DeviceCredentials accessToken(String token) {
DeviceCredentials c = new DeviceCredentials();
c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN);
c.setCredentialsId(token);
return c;
}
private static DeviceCredentials mqttBasic(String clientId, String userName, String password) {
BasicMqttCredentials inner = new BasicMqttCredentials();
inner.setClientId(clientId);
inner.setUserName(userName);
inner.setPassword(password);
DeviceCredentials c = new DeviceCredentials();
c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC);
c.setCredentialsId("mqtt-credentials-id");
c.setCredentialsValue(JacksonUtil.toString(inner));
return c;
}
private static void assertNoInjectedSiblingKeys(String yaml) throws IOException {
for (String line : yaml.split("\n")) {
String trimmed = line.replaceFirst("^\\s+", "");
assertThat(trimmed)
.as("unexpected sibling key — possible YAML injection: %s", line)
.doesNotStartWith("entrypoint:")
.doesNotStartWith("privileged:")
.doesNotStartWith("command:");
}
}
} }

2
edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>edqs</artifactId> <artifactId>edqs</artifactId>

2
monitoring/pom.xml

@ -21,7 +21,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>

2
msa/black-box-tests/pom.xml

@ -21,7 +21,7 @@
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>

2
msa/edqs/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>

2
msa/js-executor/package.json

@ -1,7 +1,7 @@
{ {
"name": "thingsboard-js-executor", "name": "thingsboard-js-executor",
"private": true, "private": true,
"version": "4.3.1.2", "version": "4.3.1.3",
"description": "ThingsBoard JavaScript Executor Microservice", "description": "ThingsBoard JavaScript Executor Microservice",
"main": "server.ts", "main": "server.ts",
"bin": "server.js", "bin": "server.js",

2
msa/js-executor/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>

2
msa/monitoring/pom.xml

@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>

2
msa/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>thingsboard</artifactId> <artifactId>thingsboard</artifactId>
</parent> </parent>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>

2
msa/tb-node/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>

2
msa/tb/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>msa</artifactId> <artifactId>msa</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>

2
msa/transport/coap/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa.transport</groupId> <groupId>org.thingsboard.msa.transport</groupId>

2
msa/transport/http/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa.transport</groupId> <groupId>org.thingsboard.msa.transport</groupId>

2
msa/transport/lwm2m/pom.xml

@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.thingsboard.msa</groupId> <groupId>org.thingsboard.msa</groupId>
<version>4.3.1.2-SNAPSHOT</version> <version>4.3.1.3-SNAPSHOT</version>
<artifactId>transport</artifactId> <artifactId>transport</artifactId>
</parent> </parent>
<groupId>org.thingsboard.msa.transport</groupId> <groupId>org.thingsboard.msa.transport</groupId>

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

Loading…
Cancel
Save