Browse Source

Merge branch 'lts-4.2' into feature/iot-hub

pull/15682/head
Igor Kulikov 2 weeks ago
parent
commit
65db41b95c
  1. 1
      TEST_FAST.md
  2. 6
      application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
  3. 131
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  4. 5
      application/src/main/resources/thingsboard.yml
  5. 17
      application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
  6. 105
      application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
  7. 126
      application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
  8. 84
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
  9. 296
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
  10. 5
      common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java
  11. 146
      common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java
  12. 3
      common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java
  13. 8
      common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java
  14. 35
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  15. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java
  16. 4
      edqs/src/main/resources/edqs.yml
  17. 10
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
  18. 73
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java
  19. 1
      msa/black-box-tests/src/test/resources/connectivity.xml
  20. 25
      msa/js-executor/api/jsExecutor.ts
  21. 2
      msa/js-executor/config/custom-environment-variables.yml
  22. 8
      msa/js-executor/config/default.yml
  23. 8
      msa/js-executor/package.json
  24. 11
      msa/js-executor/pom.xml
  25. 27
      msa/js-executor/queue/kafkaTemplate.ts
  26. 83
      msa/js-executor/test/jsExecutor.test.ts
  27. 2
      msa/js-executor/tsconfig.json
  28. 105
      msa/js-executor/yarn.lock
  29. 2
      msa/vc-executor/src/main/resources/tb-vc-executor.yml
  30. 2
      msa/web-ui/package.json
  31. 36
      pom.xml
  32. 4
      transport/coap/src/main/resources/tb-coap-transport.yml
  33. 2
      transport/http/src/main/resources/tb-http-transport.yml
  34. 4
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  35. 4
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  36. 4
      transport/snmp/src/main/resources/tb-snmp-transport.yml
  37. 2
      ui-ngx/src/app/shared/import-export/import-export.models.ts
  38. 20
      ui-ngx/src/app/shared/models/page/page-link.ts

1
TEST_FAST.md

@ -10,6 +10,7 @@ export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError"
mvn clean install -T6 -DskipTests -Dpkg.skip=true
mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4
mvn test -pl='msa/js-executor'
mvn test -pl dao -Dparallel=packages -DforkCount=4
mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5

6
application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java

@ -135,6 +135,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
}
public Path getWidgetBundlesDir() {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
}
public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@ -207,7 +211,7 @@ public class InstallScripts {
public void loadSystemWidgets() {
log.info("Loading system widgets");
Map<Path, JsonNode> widgetsBundlesMap = new HashMap<>();
Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(
path -> {

131
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -28,8 +28,10 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
@ -42,6 +44,9 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@ -67,6 +72,7 @@ public class SystemPatchApplier {
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService;
@PostConstruct
@ -100,8 +106,11 @@ public class SystemPatchApplier {
updateSqlViews();
log.info("Updated sql database views");
int updated = updateWidgetTypes();
log.info("Updated {} widget types", updated);
WidgetTypeStats widgetStats = updateWidgetTypes();
log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated());
int updatedBundles = updateWidgetBundles();
log.info("System widget bundles: {} updated", updatedBundles);
int createdImages = createMissingSystemImages();
log.info("Created {} new system images", createdImages);
@ -171,20 +180,24 @@ public class SystemPatchApplier {
}
}
private int updateWidgetTypes() {
private WidgetTypeStats updateWidgetTypes() {
AtomicInteger created = new AtomicInteger();
AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir);
return 0;
return new WidgetTypeStats(0, 0);
}
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(
path -> {
try {
if (updateWidgetTypeFromFile(path)) {
WidgetTypeChange change = updateWidgetTypeFromFile(path);
if (change == WidgetTypeChange.CREATED) {
created.incrementAndGet();
} else if (change == WidgetTypeChange.UPDATED) {
updated.incrementAndGet();
}
} catch (Exception e) {
@ -195,18 +208,26 @@ public class SystemPatchApplier {
);
}
return updated.get();
return new WidgetTypeStats(created.get(), updated.get());
}
private boolean updateWidgetTypeFromFile(Path filePath) {
private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
return saveOrUpdateSystemWidgetType(fileWidgetType);
}
private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) {
String fqn = fileWidgetType.getFqn();
if (fqn == null || fqn.isBlank()) {
throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName());
}
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) {
// We expect only update here, so it's probably never happening, but for test purpose leave it like this:
throw new RuntimeException("Widget type not found: " + fqn);
widgetTypeService.saveWidgetType(fileWidgetType);
log.trace("Created widget type: {}", fqn);
return WidgetTypeChange.CREATED;
}
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription());
@ -214,11 +235,95 @@ public class SystemPatchApplier {
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn);
return true;
return WidgetTypeChange.UPDATED;
}
log.trace("Widget type unchanged: {}", fqn);
return false;
return WidgetTypeChange.UNCHANGED;
}
private int updateWidgetBundles() {
AtomicInteger updated = new AtomicInteger();
Path widgetBundlesDir = installScripts.getWidgetBundlesDir();
if (!Files.exists(widgetBundlesDir)) {
log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir);
return 0;
}
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(path -> {
try {
if (processWidgetBundleFile(path)) {
updated.incrementAndGet();
}
} catch (Exception e) {
log.error("Unable to process widgets bundle from json: [{}]", path);
throw new RuntimeException("Unable to process widgets bundle from json", e);
}
});
}
return updated.get();
}
private boolean processWidgetBundleFile(Path filePath) {
JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile());
if (bundleJson == null || !bundleJson.has("widgetsBundle")) {
throw new RuntimeException("Invalid widgets bundle json: " + filePath);
}
WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class);
String alias = fileBundle.getAlias();
if (alias == null || alias.isBlank()) {
throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath);
}
WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
if (existingBundle == null) {
log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias);
return false;
}
if (bundleJson.has("widgetTypes")) {
throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " +
"place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath);
}
List<String> fileWidgetFqns = new ArrayList<>();
if (bundleJson.has("widgetTypeFqns")) {
bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText()));
}
boolean changed = false;
if (isWidgetsBundleChanged(existingBundle, fileBundle)) {
existingBundle.setTitle(fileBundle.getTitle());
existingBundle.setDescription(fileBundle.getDescription());
existingBundle.setImage(fileBundle.getImage());
existingBundle.setOrder(fileBundle.getOrder());
existingBundle.setScada(fileBundle.isScada());
widgetsBundleService.saveWidgetsBundle(existingBundle);
log.trace("Updated widgets bundle metadata: {}", alias);
changed = true;
}
List<String> existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
LinkedHashSet<String> mergedFqns = new LinkedHashSet<>(existingFqns);
if (mergedFqns.addAll(fileWidgetFqns)) {
widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns));
log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias);
changed = true;
}
return changed;
}
private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) {
// Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores
// the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare
// would always report a diff and re-save every system bundle on every patch run. Image content changes
// are out of scope for the patch applier — full reinstall covers them.
return !Objects.equals(existing.getTitle(), file.getTitle())
|| !Objects.equals(existing.getDescription(), file.getDescription())
|| !Objects.equals(existing.getOrder(), file.getOrder())
|| existing.isScada() != file.isScada();
}
private int createMissingSystemImages() {
@ -349,4 +454,8 @@ public class SystemPatchApplier {
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
public record WidgetTypeStats(int created, int updated) {}
private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }
}

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

@ -454,6 +454,7 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order.
postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"
@ -1688,8 +1689,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

17
application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java

@ -405,4 +405,21 @@ public class RuleChainControllerTest extends AbstractControllerTest {
return doPost("/api/ruleChain", ruleChain, RuleChain.class);
}
@Test
public void testScriptForbiddenForCustomer() throws Exception {
loginCustomerUser();
doPost("/api/ruleChain/testScript", (Object) """
{
"script": "return msg;",
"scriptType": "update",
"argNames": ["msg", "metadata", "msgType"],
"msg": "{}",
"metadata": {},
"msgType": "POST_TELEMETRY_REQUEST"
}
""")
.andExpect(status().isForbidden());
}
}

105
application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java

@ -16,19 +16,27 @@
package org.thingsboard.server.service.entitiy;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.IdBased;
import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@ -43,10 +51,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
@DaoSqlTest
@TestPropertySource(properties = {
@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest {
assetService.deleteAssetsByTenantId(tenantId);
}
// edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
@Override
@Test
public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1"));
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", ""));
}
// edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
@Override
@Test
public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true"));
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", ""));
}
@Override
protected PageData<EntityData> findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) {
return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query),

126
application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java

@ -26,6 +26,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation;
import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao;
import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository;
import org.thingsboard.server.dao.sql.relation.RelationRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE;
@Slf4j
@DaoSqlTest
@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest {
@Autowired
RelationRepository relationRepository;
@Autowired
DefaultEntityQueryRepository entityQueryRepository;
@Autowired
RelationService relationService;
@Autowired
TimeseriesService timeseriesService;
@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest {
deviceService.deleteDevicesByTenantId(tenantId);
}
@Test
public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
try {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
assertNullsOrdering("default",
List.of("0", "0", "1", "", ""),
List.of("", "", "1", "0", "0"),
devices.size());
assertNullsOrdering("nulls_first",
List.of("", "", "0", "0", "1"),
List.of("", "", "1", "0", "0"),
devices.size());
assertNullsOrdering("nulls_last",
List.of("0", "0", "1", "", ""),
List.of("1", "0", "0", "", ""),
devices.size());
} finally {
deviceService.deleteDevicesByTenantId(tenantId);
}
}
@Test
public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
try {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
assertNullsOrdering("default",
List.of("false", "false", "true", "", ""),
List.of("", "", "true", "false", "false"),
devices.size());
assertNullsOrdering("nulls_first",
List.of("", "", "false", "false", "true"),
List.of("", "", "true", "false", "false"),
devices.size());
assertNullsOrdering("nulls_last",
List.of("false", "false", "true", "", ""),
List.of("true", "false", "false", "", ""),
devices.size());
} finally {
deviceService.deleteDevicesByTenantId(tenantId);
}
}
private void assertNullsOrdering(String strategy, List<String> expectedAsc, List<String> expectedDesc, int deviceSize) {
String originalStrategy = entityQueryRepository.getNullsOrderStrategy();
ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy);
try {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, deviceSize).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc);
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, deviceSize).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc);
} finally {
ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy);
}
}
@Test
public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin
@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest {
return timeseriesService.save(tenantId, entityId, timeseries);
}
private ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Long value) {
protected ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Long value) {
KvEntry telemetryValue = new LongDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries);
}
protected ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Boolean value) {
KvEntry telemetryValue = new BooleanDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries);
}
protected void createMultiRootHierarchy(List<Asset> buildings, List<Asset> apartments,
Map<String, Map<UUID, String>> entityNameByTypeMap,
Map<UUID, UUID> childParentRelationMap) throws InterruptedException {

84
application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java

@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest {
assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull();
}
@Test
void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler");
});
}
@Test
void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile");
});
}
@Test
void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler");
});
}
@Test
void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile");
});
}
@Test
void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getMessage()).contains("unresolvable property or identifier: java");
});
}
@Test
void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider");
});
}
private void assertThatScriptIsBlocked(UUID scriptId) {
assertThatThrownBy(() -> {
invokeScriptResultString(scriptId, "{}");

296
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -31,9 +31,12 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier;
@ -41,6 +44,7 @@ import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
@ -81,6 +85,9 @@ public class SystemPatchApplierTest {
@Mock
private WidgetTypeService widgetTypeService;
@Mock
private WidgetsBundleService widgetsBundleService;
@Mock
private ImageService imageService;
@ -155,19 +162,72 @@ public class SystemPatchApplierTest {
}
@Test
void whenWidgetNotFound_thenThrowException() throws Exception {
void whenWidgetNotFound_thenCreateNewWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget");
String json = JacksonUtil.toString(testWidget);
WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget");
String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
Files.writeString(widgetTypesDir.resolve("new_widget.json"), json);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn())));
}
@Test
void whenFqnIsBlank_thenThrowException() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null);
WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget");
String json = JacksonUtil.toString(brokenWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("broken.json"), json);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
verify(widgetTypeService, never()).saveWidgetType(any());
}
@Test
void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New");
Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget));
WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name");
Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget));
WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same");
Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget));
WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name");
existingChanged.setId(new WidgetTypeId(UUID.randomUUID()));
WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same");
existingSame.setId(new WidgetTypeId(UUID.randomUUID()));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService, times(2)).saveWidgetType(any());
}
@Test
@ -189,9 +249,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2
));
@ -214,9 +276,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
}
@ -237,9 +301,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(0, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService, never()).saveWidgetType(any());
}
@ -339,8 +405,8 @@ public class SystemPatchApplierTest {
// Simulate work while holding lock
Thread.sleep(100);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(updated != null && updated > 0);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@ -360,8 +426,8 @@ public class SystemPatchApplierTest {
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) {
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(updated != null && updated > 0);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@ -560,6 +626,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -607,6 +674,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -834,6 +902,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
@ -854,4 +923,201 @@ public class SystemPatchApplierTest {
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
// --- updateWidgetBundles tests ---
@Test
void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() {
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService).updateWidgetsBundleWidgetFqns(
eq(TenantId.SYS_TENANT_ID),
eq(existingBundle.getId()),
argThat(fqns -> fqns.size() == 3
&& fqns.get(0).equals("line_chart")
&& fqns.get(1).equals("bar_chart")
&& fqns.get(2).equals("new_chart"))
);
}
@Test
void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
// File carries a base64 data URI; DB has the resolved system-image URL — same content, different format.
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," +
"\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," +
"\"widgetTypeFqns\":[]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
existingBundle.setImage("tb-image;/api/images/system/charts.png");
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of());
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleMetadataChanged_thenUpdateBundle() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Old Title");
existingBundle.setDescription("old");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService).saveWidgetsBundle(argThat(b ->
"New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20
));
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleAliasIsBlank_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"),
"{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypes\":[" +
"{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" +
"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetTypeService, never()).saveWidgetType(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
private WidgetsBundle createTestBundle(String alias, String title) {
WidgetsBundle bundle = new WidgetsBundle();
bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
bundle.setAlias(alias);
bundle.setTitle(title);
bundle.setTenantId(TenantId.SYS_TENANT_ID);
return bundle;
}
}

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

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

146
common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java

@ -0,0 +1,146 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.queue.common;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.queue.TbQueueMsg;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.never;
import static org.mockito.BDDMockito.spy;
import static org.mockito.BDDMockito.times;
import static org.mockito.BDDMockito.verify;
@Slf4j
@ExtendWith(MockitoExtension.class)
public class AbstractTbQueueConsumerTemplateTest {
private static final long POLL_DURATION_MS = 100L;
private static final long SLEEP_TOLERANCE_MS = 20L;
@Test
public void givenEmptyPartitionsAndLongPollingSupported_whenPoll_thenSleepsAndDoesNotCallDoPoll() {
// Regression: with empty partitions AND isLongPollingSupported()==true (e.g. Kafka),
// poll() previously returned instantly with no sleep, causing the consumer loop to busy-spin.
TestConsumer consumer = spy(new TestConsumer("test-topic", true));
consumer.subscribe(Collections.emptySet());
long startNs = System.nanoTime();
List<TbQueueMsg> result = consumer.poll(POLL_DURATION_MS);
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
assertThat(result, is(empty()));
verify(consumer, never()).doPoll(anyLong());
assertThat("poll() must sleep ~durationInMillis when partitions are empty (no busy-wait)",
elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
}
@Test
public void givenEmptyPartitionsAndNoLongPolling_whenPoll_thenSleepsAndDoesNotCallDoPoll() {
TestConsumer consumer = spy(new TestConsumer("test-topic", false));
consumer.subscribe(Collections.emptySet());
long startNs = System.nanoTime();
List<TbQueueMsg> result = consumer.poll(POLL_DURATION_MS);
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
assertThat(result, is(empty()));
verify(consumer, never()).doPoll(anyLong());
assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
}
@Test
public void givenNonEmptyPartitions_whenPoll_thenCallsDoPoll() {
TestConsumer consumer = spy(new TestConsumer("test-topic", true));
consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true)));
List<TbQueueMsg> result = consumer.poll(POLL_DURATION_MS);
assertThat(result, is(empty()));
verify(consumer, times(1)).doPoll(POLL_DURATION_MS);
}
@Test
public void givenPartitionsBecomeEmptyAfterRebalance_whenPollAgain_thenStopsCallingDoPoll() {
// Reproduces the observed trigger: a rebalance leaves the consumer with an empty
// partition assignment. Subsequent poll() calls must not busy-spin or call doPoll().
TestConsumer consumer = spy(new TestConsumer("test-topic", true));
consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true)));
consumer.poll(POLL_DURATION_MS);
verify(consumer, times(1)).doPoll(POLL_DURATION_MS);
consumer.subscribe(Collections.emptySet());
long startNs = System.nanoTime();
List<TbQueueMsg> result = consumer.poll(POLL_DURATION_MS);
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs);
assertThat(result, is(empty()));
verify(consumer, times(1)).doPoll(anyLong());
assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS));
}
static class TestConsumer extends AbstractTbQueueConsumerTemplate<Object, TbQueueMsg> {
private final boolean longPollingSupported;
TestConsumer(String topic, boolean longPollingSupported) {
super(topic);
this.longPollingSupported = longPollingSupported;
}
@Override
protected List<Object> doPoll(long durationInMillis) {
return Collections.emptyList();
}
@Override
protected TbQueueMsg decode(Object record) {
return null;
}
@Override
protected void doSubscribe(Set<TopicPartitionInfo> partitions) {
}
@Override
protected void doCommit() {
}
@Override
protected void doUnsubscribe() {
}
@Override
protected boolean isLongPollingSupported() {
return longPollingSupported;
}
}
}

3
common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java

@ -160,7 +160,6 @@ public class SnmpTransportContext extends TransportContext {
return;
}
sessions.put(device.getId(), sessionContext);
snmpTransportService.createQueryingTasks(sessionContext);
log.info("Established SNMP device session for device {}", device.getId());
}
@ -224,6 +223,8 @@ public class SnmpTransportContext extends TransportContext {
registerTransportSession(sessionContext, msg);
});
transportService.lifecycleEvent(sessionContext.getTenantId(), sessionContext.getDeviceId(), ComponentLifecycleEvent.STARTED, true, null);
snmpTransportService.createQueryingTasks(sessionContext);
log.info("[{}] Session registered and querying tasks created", sessionContext.getDeviceId());
} else {
log.warn("[{}] Failed to process device auth", sessionContext.getDeviceId());
}

8
common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java

@ -164,7 +164,7 @@ public class SnmpTransportService implements TbTransportService, CommandResponde
ScheduledTask scheduledTask = new ScheduledTask();
scheduledTask.init(() -> {
try {
if (sessionContext.isActive()) {
if (sessionContext.isActive() && sessionContext.isConnected()) {
return sendRequest(sessionContext, repeatingCommunicationConfig);
}
} catch (Exception e) {
@ -390,7 +390,11 @@ public class SnmpTransportService implements TbTransportService, CommandResponde
JsonObject responseData = responseDataMappers.get(requestContext.getCommunicationSpec()).map(response, requestContext);
if (responseData.size() == 0) {
log.warn("[{}] No values in the response", sessionContext.getDeviceId());
log.warn("[{}] No values in the response for spec {}. Response PDUs count: {}, Mappings count: {}",
sessionContext.getDeviceId(), requestContext.getCommunicationSpec(),
response.size(), requestContext.getResponseMappings().size());
log.debug("[{}] No values in the response for spec {}. Response PDUs: {}",
sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), response);
throw new IllegalArgumentException("No values in the response");
}

35
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao.sql.query;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -60,6 +61,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@ -318,10 +320,19 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
.replace("$in", "from").replace("$out", "to")
.replace("$rootIdCondition", "in (:relation_root_ids)");
private static final String NULLS_ORDER_DEFAULT = "default";
private static final String NULLS_ORDER_FIRST = "nulls_first";
private static final String NULLS_ORDER_LAST = "nulls_last";
private static final Set<String> ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of(NULLS_ORDER_DEFAULT, NULLS_ORDER_FIRST, NULLS_ORDER_LAST);
@Getter
@Value("${sql.relations.max_level:50}")
int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible
@Getter
@Value("${sql.entity_data_query_nulls_order_strategy:default}")
String nullsOrderStrategy;
private final NamedParameterJdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private final DefaultQueryLogComponent queryLog;
@ -332,6 +343,15 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
this.queryLog = queryLog;
}
@PostConstruct
void validateNullsOrderStrategy() {
if (!ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy)) {
log.error("Invalid value '{}' for sql.entity_data_query_nulls_order_strategy. Accepted values are: {}. Falling back to '{}'.",
nullsOrderStrategy, ACCEPTED_NULLS_ORDER_STRATEGIES, NULLS_ORDER_DEFAULT);
nullsOrderStrategy = NULLS_ORDER_DEFAULT;
}
}
@Override
public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) {
EntityType entityType = resolveEntityType(query.getEntityFilter());
@ -502,11 +522,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
if (sortOrderMappingOpt.isPresent()) {
EntityKeyMapping sortOrderMapping = sortOrderMappingOpt.get();
String direction = sortOrder.getDirection() == EntityDataSortOrder.Direction.ASC ? "asc" : "desc";
String nullsOrder = resolveNullsOrder();
if (sortOrderMapping.getEntityKey().getType() == EntityKeyType.ENTITY_FIELD) {
dataQuery = String.format("%s order by %s %s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, direction);
dataQuery = String.format("%s order by %s %s%s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, nullsOrder, direction);
} else {
dataQuery = String.format("%s order by %s %s, %s %s, result.id %s", dataQuery,
sortOrderMapping.getSortOrderNumAlias(), direction, sortOrderMapping.getSortOrderStrAlias(), direction, direction);
dataQuery = String.format("%s order by %s %s%s, %s %s, result.id %s", dataQuery,
sortOrderMapping.getSortOrderNumAlias(), direction, nullsOrder, sortOrderMapping.getSortOrderStrAlias(), direction, direction);
}
}
}
@ -525,6 +546,14 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
});
}
private String resolveNullsOrder() {
return switch (nullsOrderStrategy) {
case NULLS_ORDER_FIRST -> " NULLS FIRST";
case NULLS_ORDER_LAST -> " NULLS LAST";
default -> "";
};
}
private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List<EntityKeyMapping> entityFieldsFilters) {
String permissionQuery = this.buildPermissionQuery(ctx, entityFilter);
String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter);

4
dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java

@ -494,8 +494,8 @@ public class EntityKeyMapping {
String attrNumAlias = getSortOrderNumAlias();
String attrVarcharAlias = getSortOrderStrAlias();
String attrSortOrderSelection =
String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v then 1 else 0 end)) %s," +
"coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias);
String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v is null then null when %s.bool_v then 1 else 0 end)) %s," +
"coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias);
return String.join(", ", attrValSelection, attrTsSelection, attrSortOrderSelection);
} else {
return String.join(", ", attrValSelection, attrTsSelection);

4
edqs/src/main/resources/edqs.yml

@ -103,8 +103,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

10
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java

@ -328,6 +328,16 @@ public class TestRestClient {
.statusCode(HTTP_OK);
}
public JsonNode testRuleChainScript(Object body) {
return given().spec(requestSpec)
.body(body)
.post("/api/ruleChain/testScript")
.then()
.statusCode(HTTP_OK)
.extract()
.as(JsonNode.class);
}
private String getUrlParams(PageLink pageLink) {
String urlParams = "pageSize={pageSize}&page={page}";
if (!isEmpty(pageLink.getTextSearch())) {

73
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/security/JsExecutorSandboxIsolationTest.java

@ -0,0 +1,73 @@
/**
* 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.msa.security;
import com.fasterxml.jackson.databind.JsonNode;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.thingsboard.server.msa.AbstractContainerTest;
import static org.assertj.core.api.Assertions.assertThat;
public class JsExecutorSandboxIsolationTest extends AbstractContainerTest {
@BeforeClass
public void beforeClass() {
testRestClient.login("tenant@thingsboard.org", "tenant");
}
@AfterClass
public void afterClass() {
testRestClient.resetToken();
}
/**
* Black-box regression for JVN#16937365: a tenant admin must not be able
* to escape the tb-js-executor sandbox via the host-realm prototype chain
* exposed through the script's `args` argument. Runs against the live
* docker-compose deployment, which uses script.use_sandbox=true and
* JS_EVALUATOR=remote (Kafka -> tb-js-executor).
*/
@Test
public void testRuleChainScriptCannotReachHostProcess() {
JsonNode response = testRestClient.testRuleChainScript("""
{
"script": "var F = args.constructor.constructor; var p = F('return process')(); return { reachedHost: !!(p && p.mainModule) };",
"scriptType": "update",
"argNames": ["msg", "metadata", "msgType"],
"msg": "{}",
"metadata": {},
"msgType": "POST_TELEMETRY_REQUEST"
}
""");
// The sandboxed run must reject the escape attempt: the host `process`
// global is not defined inside the sandbox realm, so executing the
// synthesized function `F("return process")` throws.
assertThat(response.has("error")).isTrue();
String error = response.get("error").asText();
assertThat(error)
.as("sandbox must block host-realm reach via args.constructor.constructor; full error: %s", error)
.contains("process is not defined");
// Defense in depth: even if the script somehow returned, output must
// not indicate that the host process was reached.
if (response.hasNonNull("output")) {
assertThat(response.get("output").asText()).doesNotContain("\"reachedHost\":true");
}
}
}

1
msa/black-box-tests/src/test/resources/connectivity.xml

@ -25,6 +25,7 @@
<package name="org.thingsboard.server.msa.edqs"/>
<package name="org.thingsboard.server.msa.cf"/>
<package name="org.thingsboard.server.msa.rule.node"/>
<package name="org.thingsboard.server.msa.security"/>
</packages>
</test>
</suite>

25
msa/js-executor/api/jsExecutor.ts

@ -15,14 +15,22 @@
///
import vm, { Script } from 'vm';
import { _logger } from '../config/logger';
export type TbScript = Script | Function;
export class JsExecutor {
useSandbox: boolean;
private logger = _logger('JsExecutor');
constructor(useSandbox: boolean) {
this.useSandbox = useSandbox;
if (!useSandbox) {
this.logger.warn(
'script.use_sandbox=false: dangerous by design — user-supplied scripts run in the host realm with no isolation. ' +
'Use only as a performance trade-off in trusted, non-public clusters.'
);
}
}
compileScript(code: string): Promise<TbScript> {
@ -56,9 +64,15 @@ export class JsExecutor {
private invokeScript(script: Script, args: string[], timeout: number | undefined): Promise<any> {
return new Promise((resolve, reject) => {
try {
const sandbox = Object.create(null);
sandbox.args = args;
const result = script.runInNewContext(sandbox, {timeout: timeout});
const sandbox = vm.createContext(Object.create(null));
// Construct args inside the sandbox context so it inherits sandbox-realm
// prototypes; prevents prototype-based escapes from the host realm.
const ctxArgs = vm.runInContext('[]', sandbox) as string[];
for (let i = 0; i < args.length; i++) {
ctxArgs[i] = String(args[i]);
}
sandbox.args = ctxArgs;
const result = script.runInContext(sandbox, {timeout: timeout});
resolve(result);
} catch (err) {
reject(err);
@ -67,6 +81,11 @@ export class JsExecutor {
}
// DANGEROUS BY DESIGN: the non-sandbox path. vm.compileFunction's
// parsingContext only isolates *parsing*, not *execution* — the resulting
// function runs in the host realm with full access to host globals
// (process, require, etc.). Enabled only via script.use_sandbox=false as
// a performance trade-off in trusted clusters.
private createFunction(code: string): Promise<Function> {
return new Promise((resolve, reject) => {
try {

2
msa/js-executor/config/custom-environment-variables.yml

@ -34,7 +34,7 @@ kafka:
partitions_consumed_concurrently: "TB_KAFKA_PARTITIONS_CONSUMED_CONCURRENTLY" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency
requestTimeout: "TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS"
connectionTimeout: "TB_KAFKA_CONNECTION_TIMEOUT_MS"
compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip or uncompressed
compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip, lz4 or none
topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES"
use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD"
client_id: "KAFKA_CLIENT_ID" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh

8
msa/js-executor/config/default.yml

@ -34,7 +34,7 @@ kafka:
partitions_consumed_concurrently: "1" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency
requestTimeout: "30000" # The default value in kafkajs is: 30000
connectionTimeout: "1000" # The default value in kafkajs is: 1000
compression: "none" # gzip or uncompressed
compression: "none" # gzip, lz4 or none
topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1"
use_confluent_cloud: false
client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh
@ -50,6 +50,12 @@ logger:
filename: "tb-js-executor-%DATE%.log"
script:
# WARNING: setting this to "false" is DANGEROUS BY DESIGN. The non-sandbox
# path compiles and runs user-supplied scripts in the host realm via
# vm.compileFunction; it provides no isolation and exposes the host process
# (file system, environment variables, child_process, etc.) to script
# authors. Use "false" only as a performance trade-off in trusted,
# non-public clusters where every script author is fully trusted.
use_sandbox: "true"
memory_usage_trace_frequency: "1000"
script_body_trace_frequency: "10000"

8
msa/js-executor/package.json

@ -6,13 +6,14 @@
"main": "server.ts",
"bin": "server.js",
"scripts": {
"pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js",
"test": "echo \"Error: no test specified\" && exit 1",
"pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-js-executor-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-js-executor-win.exe ./target/src && node install.js",
"test": "mkdir -p target/surefire-reports && node --require ts-node/register --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=target/surefire-reports/TEST-js-executor.xml test/jsExecutor.test.ts",
"start": "nodemon --watch '.' --ext 'ts' --exec 'ts-node server.ts'",
"start-prod": "nodemon --watch '.' --ext 'ts' --exec 'NODE_ENV=production ts-node server.ts'",
"build": "tsc"
},
"dependencies": {
"@2l/kafkajs-lz4": "^1.3.2",
"config": "^4.1.1",
"express": "^5.1.0",
"js-yaml": "^4.1.1",
@ -46,7 +47,8 @@
},
"pkg": {
"assets": [
"node_modules/config/**/*.*"
"node_modules/config/**/*.*",
"node_modules/@antoniomuso/lz4-napi-*/**/*.node"
]
}
}

11
msa/js-executor/pom.xml

@ -116,6 +116,17 @@
<arguments>--mutex network run pkg</arguments>
</configuration>
</execution>
<execution>
<id>yarn test</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>test</phase>
<configuration>
<skip>${maven.test.skip}</skip>
<arguments>--mutex network run test</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>

27
msa/js-executor/queue/kafkaTemplate.ts

@ -21,6 +21,7 @@ import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor'
import { IQueue } from './queue.models';
import {
Admin,
CompressionCodecs,
CompressionTypes,
Consumer,
Kafka,
@ -46,7 +47,31 @@ export class KafkaTemplate implements IQueue {
private linger = Number(config.get('kafka.linger_ms'));
private requestTimeout = Number(config.get('kafka.requestTimeout'));
private connectionTimeout = Number(config.get('kafka.connectionTimeout'));
private compressionType = (config.get('kafka.compression') === "gzip") ? CompressionTypes.GZIP : CompressionTypes.None;
private compressionType = this.resolveCompressionType(config.get('kafka.compression'));
private resolveCompressionType(compression: string): CompressionTypes {
switch (compression) {
case 'gzip':
return CompressionTypes.GZIP;
case 'lz4': {
// Load the LZ4 codec lazily so users who don't enable LZ4 don't take a hard
// dependency on the lz4-napi native binary (e.g. inside pkg-built executables).
// The package re-assigns module.exports = LZ4Codec, which wipes the __esModule
// flag and the .default property — so accept either shape.
const lz4Module = require('@2l/kafkajs-lz4');
const LZ4Codec = lz4Module.default || lz4Module;
CompressionCodecs[CompressionTypes.LZ4] = new LZ4Codec().codec;
return CompressionTypes.LZ4;
}
case 'none':
return CompressionTypes.None;
default:
if (isNotEmptyStr(compression)) {
this.logger.warn('Unknown kafka.compression value "%s"; falling back to no compression. Supported values: gzip, lz4, none.', compression);
}
return CompressionTypes.None;
}
}
private partitionsConsumedConcurrently = Number(config.get('kafka.partitions_consumed_concurrently'));
private kafkaClient: Kafka;

83
msa/js-executor/test/jsExecutor.test.ts

@ -0,0 +1,83 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { JsExecutor } from '../api/jsExecutor';
// describe('js-executor') groups all cases under <testsuite name="js-executor">
// in the JUnit XML so they show up under that suite in TeamCity's Tests tab,
// alongside thousands of Java tests.
describe('js-executor', () => {
test('sandbox isolates args from host realm (JVN#16937365)', async () => {
const exec = new JsExecutor(true);
const script = await exec.compileScript(`function(msg, metadata, msgType){
var F = args.constructor.constructor;
var p = F("return process")();
return p && p.mainModule ? 'reached-host' : 'isolated';
}`);
await assert.rejects(
exec.executeScript(script, ['{}', '{}', 'POST_TELEMETRY_REQUEST'], 5000),
/process is not defined/,
'host process must not be reachable from inside the sandbox',
);
});
test('sandbox passes string args through unchanged', async () => {
const exec = new JsExecutor(true);
const script = await exec.compileScript(`function(msg, metadata, msgType){
return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
}`);
const out = await exec.executeScript(script, ['hello', '{}', 'X'], 5000);
// Field-by-field: the returned object is owned by the sandbox realm, so
// its prototype is not the host Object.prototype and deepStrictEqual would
// reject it on prototype mismatch even when the values match.
assert.equal(out.msgIsString, true);
assert.equal(out.count, 3);
assert.equal(out.first, 'hello');
});
// The use_sandbox=false path is intentionally non-isolating: scripts compile
// and run in the host realm via vm.compileFunction. The two tests below codify
// that documented contract so any future behavior change shows up as a test
// failure and forces a deliberate update of the docs and threat model.
test('non-sandbox path does not isolate from host realm (documented contract)', async () => {
const exec = new JsExecutor(false);
const script = await exec.compileScript(`function(msg, metadata, msgType){
// Non-destructive host-reach probe: typeof process.platform is 'string'
// only if the host process object is reachable.
var F = args.constructor.constructor;
return F('return typeof process.platform')();
}`);
const out = await exec.executeScript(script, ['{}', '{}', 'X']);
assert.equal(out, 'string',
'use_sandbox=false is documented as non-isolating; if this fails, the path was changed and docs/threat model must be updated');
});
test('non-sandbox path passes string args through unchanged', async () => {
const exec = new JsExecutor(false);
const script = await exec.compileScript(`function(msg, metadata, msgType){
return { msgIsString: typeof msg === 'string', count: args.length, first: args[0] };
}`);
const out = await exec.executeScript(script, ['hello', '{}', 'X']);
assert.equal(out.msgIsString, true);
assert.equal(out.count, 3);
assert.equal(out.first, 'hello');
});
}); // describe('js-executor')

2
msa/js-executor/tsconfig.json

@ -9,5 +9,5 @@
"skipLibCheck": true,
"strictPropertyInitialization": false
},
"exclude": ["node_modules", "target"]
"exclude": ["node_modules", "target", "test"]
}

105
msa/js-executor/yarn.lock

@ -2,6 +2,78 @@
# yarn lockfile v1
"@2l/kafkajs-lz4@^1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@2l/kafkajs-lz4/-/kafkajs-lz4-1.3.2.tgz#25259f693a709816ef4eb62514c1ce1bab80502f"
integrity sha512-yq5dx4CbL2sofWXKuadyty3ZKRcZyqe5iiuuOWjZgtIed9MBZ2Wqe5hQN18+a5XVfR4SFQGVISb/a0oTjdPjwQ==
dependencies:
lz4-napi "^2.8.0"
"@antoniomuso/lz4-napi-android-arm-eabi@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm-eabi/-/lz4-napi-android-arm-eabi-2.9.0.tgz#5ea67847f0a761c9aec22a31212d0e429ca01fb2"
integrity sha512-aeT/9SoWq7rnmzssWuCKUPaxVt3fzE9q+xq/ZHbnUSmrm8/EhLOACMvQeCOnL0IZsmPh8EpuwIE1TZyM9iQPRA==
"@antoniomuso/lz4-napi-android-arm64@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm64/-/lz4-napi-android-arm64-2.9.0.tgz#64cb0aac70267bb071bba2d8e382301e058811ca"
integrity sha512-ibQ0qiEvmljXAM97IgOZfh+PeiSQ0Rqf2HErJlZPVm2v4GVJoB67v21v1TUydqNNV5L8bwufVoZ90nheL8X9ZA==
"@antoniomuso/lz4-napi-darwin-arm64@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-arm64/-/lz4-napi-darwin-arm64-2.9.0.tgz#669e48b165af11cec20581acc2caa0d1c0f84472"
integrity sha512-1su4K1MWa4bcWoZlHajv+luGmFDV1JwIsvjtDF+0HhUveSDPP+8A4Z34zOZidURIr08Sl7M7ViPth6ZQ9SqnAA==
"@antoniomuso/lz4-napi-darwin-x64@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-x64/-/lz4-napi-darwin-x64-2.9.0.tgz#a00ddf021772e26bd01b3ca5e3025f11d1667edf"
integrity sha512-8Lnbm2MkdJtiJ/nbcRS9zRyGp3G0sG6D+Y/x1vTP8nZs3/f8tBwYNsjxCQyyXNNyHcYWwVGbk68onP/pyDljOA==
"@antoniomuso/lz4-napi-freebsd-x64@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-freebsd-x64/-/lz4-napi-freebsd-x64-2.9.0.tgz#fdc2b292489bd6d4e44eb7bb059fb9bd4b860f14"
integrity sha512-k04EMVOjntKDPrdR4Tf8WyNseuk9PTtSGw8WHyp4CTjoR1s+YJxtp9SMnThe5o2q0TATwk8WGYb/Howrp5OMxw==
"@antoniomuso/lz4-napi-linux-arm-gnueabihf@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm-gnueabihf/-/lz4-napi-linux-arm-gnueabihf-2.9.0.tgz#9fcdf93b3ca5aa24469e4bf11dd7100df8c71975"
integrity sha512-H92F8zPZmgy2r8IhCWh3qIBfLp2BQ5cp18RoDXhtGFWwkh+5gVWrZp11IVznrsdgB0QeW0VR7dAMMHg3WLOPfA==
"@antoniomuso/lz4-napi-linux-arm64-gnu@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-gnu/-/lz4-napi-linux-arm64-gnu-2.9.0.tgz#9d22e075a9cb60c3cb415428138618ae4a79f3c5"
integrity sha512-25crh0qs/3Rj3fMI8ulYD0DoaKsidUhMBki2aeO69ZK+F8bmQ/e2++FlgJ6f3EgMP5CNxJtnZXKhPOraQWjwAw==
"@antoniomuso/lz4-napi-linux-arm64-musl@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-musl/-/lz4-napi-linux-arm64-musl-2.9.0.tgz#d957bce3accd49199bb6de522f5163efa2068fe4"
integrity sha512-eJtHp38zuLaYI0/cOV/BKcNQiXUBo4GPx53FTf0Y307yUjLsn48LNeN0vD28Ct9YrbUae3bQvMD5AD86She0ww==
"@antoniomuso/lz4-napi-linux-x64-gnu@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-gnu/-/lz4-napi-linux-x64-gnu-2.9.0.tgz#c86567d5aa23863059afbee829fb9f45895d3869"
integrity sha512-mDjS4dyjRKaZQcAP71SphkYH5r3kufB30ih/VETVu/br2toCfBk6Zr1xhL1r+L7FaVAFzF62B7h30CiqrN0Awg==
"@antoniomuso/lz4-napi-linux-x64-musl@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-musl/-/lz4-napi-linux-x64-musl-2.9.0.tgz#7d02612dec7d6247645aa321871b15247da0ce7d"
integrity sha512-pvU7Z7qjkjn17NkddBtBQ7C2iRqjtZ7WJ3Jqrjtj4XxolY3Q0HaYMvWjkWhzb9AKGZbj5y+EHYtbVoZJ2TSQhQ==
"@antoniomuso/lz4-napi-win32-arm64-msvc@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-arm64-msvc/-/lz4-napi-win32-arm64-msvc-2.9.0.tgz#c901bfec718303ed86867129ecc0371eaa883517"
integrity sha512-aioLlbpJl0QPEXLXhh2bzyitc3T7Jot3f1ap6WdKiRa+CIjMHXw1nxJXy07MLXif10r+qVZr86ic8dvwErgqEQ==
"@antoniomuso/lz4-napi-win32-ia32-msvc@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-ia32-msvc/-/lz4-napi-win32-ia32-msvc-2.9.0.tgz#11e48b18b7923c250735e0130998e5968ae91130"
integrity sha512-VaF4XMTdYb59TsPsiqnWwsNaWKHhgxF33z5p4zg4n0tp20eWozl76hn8B+aXthSs40W0W1N97QhxxV4oXGd8cg==
"@antoniomuso/lz4-napi-win32-x64-msvc@2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-x64-msvc/-/lz4-napi-win32-x64-msvc-2.9.0.tgz#20d9f71a638f3277cd0b7662e966f90e53d98af8"
integrity sha512-wfA8ShO3eGLxJ1LDwXJo87XL2D4NkMJV1pfHPvLZpD0MWb9u8VfgS+gKK5YhT7XKjzVdeIna9jgFdn2HBnZBxA==
"@babel/generator@^7.23.0":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e"
@ -100,6 +172,18 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@napi-rs/triples@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.2.0.tgz#bcd9c936acb93890e7015818e0181f3db421aafa"
integrity sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==
"@node-rs/helper@^1.3.3":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@node-rs/helper/-/helper-1.6.0.tgz#83e5381de6e898d0b8c92178bb8d897d619e3a3a"
integrity sha512-2OTh/tokcLA1qom1zuCJm2gQzaZljCCbtX1YCrwRVd/toz7KxaDRFeLTAPwhs8m9hWgzrBn5rShRm6IaZofCPw==
dependencies:
"@napi-rs/triples" "^1.2.0"
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@ -998,6 +1082,27 @@ long@^5.3.2:
resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83"
integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
lz4-napi@^2.8.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/lz4-napi/-/lz4-napi-2.9.0.tgz#4a700974a1154f82b3c0e1b7030fe63140051e7d"
integrity sha512-ZOWqxBMIK5768aD20tYn5B6Pp9WPM9UG/LHk8neG9p0gC1DtjdzhTtlkxhAjvTRpmJvMtnnqLKlT+COlqAt9cQ==
dependencies:
"@node-rs/helper" "^1.3.3"
optionalDependencies:
"@antoniomuso/lz4-napi-android-arm-eabi" "2.9.0"
"@antoniomuso/lz4-napi-android-arm64" "2.9.0"
"@antoniomuso/lz4-napi-darwin-arm64" "2.9.0"
"@antoniomuso/lz4-napi-darwin-x64" "2.9.0"
"@antoniomuso/lz4-napi-freebsd-x64" "2.9.0"
"@antoniomuso/lz4-napi-linux-arm-gnueabihf" "2.9.0"
"@antoniomuso/lz4-napi-linux-arm64-gnu" "2.9.0"
"@antoniomuso/lz4-napi-linux-arm64-musl" "2.9.0"
"@antoniomuso/lz4-napi-linux-x64-gnu" "2.9.0"
"@antoniomuso/lz4-napi-linux-x64-musl" "2.9.0"
"@antoniomuso/lz4-napi-win32-arm64-msvc" "2.9.0"
"@antoniomuso/lz4-napi-win32-ia32-msvc" "2.9.0"
"@antoniomuso/lz4-napi-win32-x64-msvc" "2.9.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"

2
msa/vc-executor/src/main/resources/tb-vc-executor.yml

@ -73,7 +73,7 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

2
msa/web-ui/package.json

@ -6,7 +6,7 @@
"main": "server.ts",
"bin": "server.js",
"scripts": {
"pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js",
"pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-web-ui-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-web-ui-win.exe ./target/src && node install.js",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web ts-node server.ts'",
"start-prod": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web NODE_ENV=production ts-node server.ts'",

36
pom.xml

@ -69,6 +69,8 @@
<spring-boot-test.version>3.5.13</spring-boot-test.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<postgresql.version>42.7.11</postgresql.version> <!-- to fix CVE-2026-42198. TODO: remove when fixed in spring-boot-dependencies -->
<netty.version>4.1.133.Final</netty.version> <!-- to fix CVE-2026-42579, CVE-2026-42583, CVE-2026-42584, CVE-2026-42587. TODO: remove when fixed in spring-boot-dependencies -->
<tomcat.version>10.1.55</tomcat.version> <!-- to fix CVE-2026-41284, CVE-2026-43512. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
@ -92,7 +94,7 @@
<zookeeper.version>3.9.5</zookeeper.version> <!-- to fix CVE-2026-24308 and CVE-2026-24281. TODO: remove override when fixed in curator-client -->
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version>
<tbel.version>1.2.10</tbel.version>
<lombok.version>1.18.46</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
@ -141,6 +143,7 @@
<antisamy.version>1.7.5</antisamy.version>
<snmp4j.version>3.8.0</snmp4j.version>
<langchain4j.version>1.8.0-TB</langchain4j.version>
<opennlp-tools.version>2.5.9</opennlp-tools.version> <!-- to fix CVE-2026-40682, CVE-2026-42027 in transitive dep via langchain4j-bom (which still pins 2.5.4). TODO: remove when langchain4j fork ships opennlp-tools >= 2.5.9 -->
<error_prone_annotations.version>2.38.0</error_prone_annotations.version>
<animal-sniffer-annotations.version>1.24</animal-sniffer-annotations.version>
<auto-value-annotations.version>1.11.0</auto-value-annotations.version>
@ -1005,6 +1008,32 @@
<dependencyManagement>
<dependencies>
<!-- Temporary netty-bom version override -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-bom</artifactId>
<version>${netty.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- End of netty-bom version override -->
<!-- Temporary tomcat-embed version override -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of tomcat-embed version override -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
@ -1347,6 +1376,11 @@
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>org.apache.opennlp</groupId>
<artifactId>opennlp-tools</artifactId>
<version>${opennlp-tools.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>

4
transport/coap/src/main/resources/tb-coap-transport.yml

@ -285,8 +285,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

2
transport/http/src/main/resources/tb-http-transport.yml

@ -235,7 +235,7 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

4
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -335,8 +335,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

4
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -268,8 +268,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

4
transport/snmp/src/main/resources/tb-snmp-transport.yml

@ -207,8 +207,8 @@ queue:
acks: "${TB_KAFKA_ACKS:all}"
# Number of retries. Resend any record whose send fails with a potentially transient error
retries: "${TB_KAFKA_RETRIES:1}"
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip
# The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4
compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4
# Default batch size. This setting gives the upper bound of the batch size to be sent
batch.size: "${TB_KAFKA_BATCH_SIZE:16384}"
# This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record

2
ui-ngx/src/app/shared/import-export/import-export.models.ts

@ -210,7 +210,7 @@ function splitCSV(str: string, sep: string): string[] {
foo = foo.shift().split(sep).concat(foo);
}
} else {
foo[x].replace(/""/g, '"');
foo[x] = foo[x].replace(/""/g, '"');
}
}
return foo;

20
ui-ngx/src/app/shared/models/page/page-link.ts

@ -84,11 +84,25 @@ export function sortItems(item1: any, item2: any, property: string, asc: boolean
result = item1Value - item2Value;
} else if (item1Type === 'string' && item2Type === 'string') {
result = item1Value.localeCompare(item2Value);
} else if ((item1Type === 'boolean' && item2Type === 'boolean') || (item1Type !== item2Type)) {
if (item1Value && !item2Value) {
} else if (item1Type === 'boolean' && item2Type === 'boolean') {
result = item1Value ? 1 : -1;
} else if (item1Type !== item2Type) {
const item1Empty = item1Value === null || item1Value === undefined || item1Value === '';
const item2Empty = item2Value === null || item2Value === undefined || item2Value === '';
if (!item1Empty && item2Empty) {
result = 1;
} else if (!item1Value && item2Value) {
} else if (item1Empty && !item2Empty) {
result = -1;
} else if (!item1Empty && !item2Empty) {
const str1 = String(item1Value).trim();
const str2 = String(item2Value).trim();
const num1 = str1.length ? Number(str1) : NaN;
const num2 = str2.length ? Number(str2) : NaN;
if (!isNaN(num1) && !isNaN(num2)) {
result = num1 - num2;
} else {
result = String(item1Value).localeCompare(String(item2Value));
}
}
}
}

Loading…
Cancel
Save