From aabf0c3813d5f5962f47dd1382589787241ccdfe Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 7 Jan 2026 13:03:22 +0200 Subject: [PATCH 1/7] Improve validation for user credentials on login --- .../security/system/DefaultSystemSecurityService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 69536f0953..a977def292 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -80,11 +80,15 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + if (!encoder.matches(password, userCredentials.getPassword())) { int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { - if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { + if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts()) { lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); throw new LockedException("Authentication Failed. Username was locked due to security policy."); } @@ -92,10 +96,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService { throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); } - if (!userCredentials.isEnabled()) { - throw new DisabledException("User is not active"); - } - userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); From 4363e53ff4782b7e7343b05ca13c1a6c97bcc620 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 8 Jan 2026 12:35:27 +0200 Subject: [PATCH 2/7] Add DefaultSystemSecurityServiceTest --- .../DefaultSystemSecurityServiceTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java new file mode 100644 index 0000000000..05e51177af --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.system; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.settings.SecuritySettingsService; +import org.thingsboard.server.dao.user.UserService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class DefaultSystemSecurityServiceTest { + + @Mock + private AdminSettingsService adminSettingsService; + @Mock + private BCryptPasswordEncoder encoder; + @Mock + private UserService userService; + @Mock + private MailService mailService; + @Mock + private AuditLogService auditLogService; + @Mock + private SecuritySettingsService securitySettingsService; + + private DefaultSystemSecurityService systemSecurityService; + + private TenantId tenantId; + private UserId userId; + private UserCredentials userCredentials; + private SecuritySettings securitySettings; + private String username; + private String password; + private String encodedPassword; + + @Before + public void setUp() { + systemSecurityService = new DefaultSystemSecurityService(adminSettingsService, encoder, userService, mailService, auditLogService, securitySettingsService); + + tenantId = TenantId.fromUUID(UUID.randomUUID()); + userId = new UserId(UUID.randomUUID()); + username = "tenant@example.com"; + password = "correctPassword"; + encodedPassword = "$2a$10$encodedPasswordHash"; + + userCredentials = new UserCredentials(); + userCredentials.setUserId(userId); + userCredentials.setEnabled(true); + userCredentials.setPassword(encodedPassword); + userCredentials.setCreatedTime(System.currentTimeMillis()); + + securitySettings = new SecuritySettings(); + securitySettings.setMaxFailedLoginAttempts(5); + securitySettings.setPasswordPolicy(new UserPasswordPolicy()); + } + + @Test + public void testValidateUserCredentials_successfulLogin() { + when(encoder.matches(password, encodedPassword)).thenReturn(true); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password); + + verify(encoder).matches(password, encodedPassword); + verify(userService).resetFailedLoginAttempts(tenantId, userId); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_incrementsFailedAttempts() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(3); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_accountLocked() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class) + .hasMessageContaining("locked due to security policy"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + } + + @Test + public void testValidateUserCredentials_wrongPassword_exactlyAtThreshold_noLock() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(5); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_correctPassword_disabledUser_throwsDisabledException() { + userCredentials.setEnabled(false); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(DisabledException.class) + .hasMessage("User is not active"); + + verify(encoder, never()).matches(any(), any()); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsDisabled() { + securitySettings.setMaxFailedLoginAttempts(null); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsSetToZero() { + securitySettings.setMaxFailedLoginAttempts(0); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_withNotificationEmail() throws ThingsboardException { + String notificationEmail = "admin@example.com"; + securitySettings.setUserLockoutNotificationEmail(notificationEmail); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class); + + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + verify(mailService).sendAccountLockoutEmail(eq(username), eq(notificationEmail), eq(5)); + } + +} From a2da95760c91fff67ff2cf55214cb79fd2a719cb Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 22 Jan 2026 11:18:42 +0200 Subject: [PATCH 3/7] Fix API key controller, when user update description or activity --- .../server/controller/ApiKeyController.java | 10 +++++++--- .../server/controller/ApiKeyControllerTest.java | 9 ++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 2c6ae69ca5..09972bd6c2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -77,7 +77,11 @@ public class ApiKeyController extends BaseController { User user = checkUserId(apiKeyInfo.getUserId(), Operation.WRITE); apiKeyInfo.setTenantId(user.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); - return checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo)); + ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo)); + if (apiKeyInfo.getId() != null) { + savedApiKey.setValue(null); + } + return savedApiKey; } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", @@ -121,7 +125,7 @@ public class ApiKeyController extends BaseController { ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setDescription(description.orElse(null)); - return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + return new ApiKeyInfo(apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey)); } @ApiOperation(value = "Enable or disable API key (enableApiKey)", @@ -137,7 +141,7 @@ public class ApiKeyController extends BaseController { ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setEnabled(enabledValue); - return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + return new ApiKeyInfo(apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey)); } @ApiOperation(value = "Delete API key by ID (deleteApiKey)", diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 9c7f7a898d..836d1cbfad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -53,7 +53,14 @@ public class ApiKeyControllerTest extends AbstractControllerTest { Assert.assertEquals(tenantId, savedApiKey.getTenantId()); Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId()); - doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + String newDescription = "Updated API Key Description"; + savedApiKey.setDescription(newDescription); + ApiKey updatedApiKey = doPost("/api/apiKey", savedApiKey, ApiKey.class); + Assert.assertNotNull(updatedApiKey); + Assert.assertEquals(newDescription, updatedApiKey.getDescription()); + Assert.assertNull("Verify we do not expose API key value on update", updatedApiKey.getValue()); + + doDelete("/api/apiKey/" + updatedApiKey.getId()).andExpect(status().isOk()); } @Test From d568ee8536d51ceb8fa47ed199b82244b8c5bba3 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 23 Jan 2026 16:55:46 +0200 Subject: [PATCH 4/7] fix: process cfs that uses the same key and some of cfs throws error --- ...CalculatedFieldEntityMessageProcessor.java | 27 +++++- .../cf/ctx/state/CalculatedFieldCtx.java | 2 +- .../cf/CalculatedFieldIntegrationTest.java | 93 +++++++++++++++++++ .../server/controller/AbstractWebTest.java | 13 +++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 752e8a1c11..b95beb81a1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -131,11 +131,12 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state.isSizeOk()) { processStateIfReady(ctx, Collections.singletonList(ctx.getCfId()), state, null, null, msg.getCallback()); } else { - throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); } } catch (Exception e) { if (e instanceof CalculatedFieldException cfe) { - throw cfe; + persistDebugErrorIfEnabled(cfe, msg.getCallback()); + return; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -200,6 +201,10 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } } catch (Exception e) { + if (e instanceof CalculatedFieldException cfe) { + persistDebugErrorIfEnabled(cfe, callback); + return; + } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } } @@ -223,7 +228,8 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } catch (Exception e) { if (e instanceof CalculatedFieldException cfe) { - throw cfe; + persistDebugErrorIfEnabled(cfe, callback); + return; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -485,4 +491,19 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM return null; } + private void persistDebugErrorIfEnabled(CalculatedFieldException cfe, TbCallback callback) { + if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { + String message; + if (cfe.getErrorMessage() != null) { + message = cfe.getErrorMessage(); + } else if (cfe.getCause() != null) { + message = cfe.getCause().getMessage(); + } else { + message = "N/A"; + } + systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + } + callback.onSuccess(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 5d0ff5c2ff..373e2f588f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -310,7 +310,7 @@ public class CalculatedFieldCtx { } public String getSizeExceedsLimitMessage() { - return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; + return "State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; } } diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index a21b081086..7f905ec0ae 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -23,6 +23,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -36,7 +37,9 @@ import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedField import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.controller.CalculatedFieldControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -875,6 +878,96 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testCalculatedFieldsWhenOneIsInvalid() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + long now = System.currentTimeMillis(); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":5}}", now - TimeUnit.MINUTES.toMillis(3)))); + + // Script CF - invalid + CalculatedField invalidCF = new CalculatedField(); + invalidCF.setEntityId(testDevice.getId()); + invalidCF.setType(CalculatedFieldType.SCRIPT); + invalidCF.setName("Script CF"); + invalidCF.setDebugSettings(DebugSettings.all()); + + ScriptCalculatedFieldConfiguration scriptConfig = new ScriptCalculatedFieldConfiguration(); + + ReferencedEntityKey refEntityKeyA = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + Argument argumentA = new Argument(); + argumentA.setRefEntityKey(refEntityKeyA); + scriptConfig.setArguments(Map.of("a", argumentA)); + scriptConfig.setExpression(""" + return { + "temperature": temp + }; + """); + + Output scriptOutput = new Output(); + scriptOutput.setType(OutputType.TIME_SERIES); + scriptConfig.setOutput(scriptOutput); + + invalidCF.setConfiguration(scriptConfig); + + invalidCF = doPost("/api/calculatedField", invalidCF, CalculatedField.class); + CalculatedFieldId invalidCfId = invalidCF.getId(); + + await().alias("create invalid CF -> check error").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + PageData debugEvents = getDebugEvents(tenantId, invalidCfId, 1); + if (!debugEvents.getData().isEmpty()) { + EventInfo eventInfo = debugEvents.getData().get(0); + assertThat(eventInfo.getBody().has("error")).isTrue(); + } + }); + + // Simple CF - valid + CalculatedField validCF = new CalculatedField(); + validCF.setEntityId(testDevice.getId()); + validCF.setType(CalculatedFieldType.SIMPLE); + validCF.setName("Simple CF"); + validCF.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration simpleConfig = new SimpleCalculatedFieldConfiguration(); + simpleConfig.setArguments(Map.of("a", argumentA)); + simpleConfig.setExpression("a+1"); + + Output simpleOutput = new Output(); + simpleOutput.setName("a+1"); + simpleOutput.setType(OutputType.TIME_SERIES); + simpleOutput.setDecimalsByDefault(0); + simpleConfig.setOutput(simpleOutput); + + validCF.setConfiguration(simpleConfig); + + validCF = doPost("/api/calculatedField", validCF, CalculatedField.class); + CalculatedFieldId validCfId = validCF.getId(); + + await().alias("create CF -> check initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + PageData debugEvents = getDebugEvents(tenantId, validCfId, 1); + if (!debugEvents.getData().isEmpty()) { + EventInfo eventInfo = debugEvents.getData().get(0); + assertThat(eventInfo.getBody().has("error")).isFalse(); + } + ObjectNode result = getLatestTelemetry(testDevice.getId(), "a+1"); + assertThat(result).isNotNull(); + assertThat(result.get("a+1").get(0).get("value").asText()).isEqualTo("6"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":6}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "a+1"); + assertThat(result).isNotNull(); + assertThat(result.get("a+1").get(0).get("value").asText()).isEqualTo("7"); + }); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 44c383a5f0..3b7286cf01 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -80,6 +80,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResourceInfo; @@ -99,6 +100,7 @@ import org.thingsboard.server.common.data.device.profile.MqttTopics; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -1298,4 +1300,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { doPost("/api/job/" + jobId + "/reprocess").andExpect(status().isOk()); } + protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { + return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE, limit); + } + + protected PageData getEvents(TenantId tenantId, EntityId entityId, EventType eventType, int limit) throws Exception { + TimePageLink pageLink = new TimePageLink(limit); + return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", + new TypeReference>() { + }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); + } + } From 202d4587118d54410be858bc293ae4f375e20b29 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 26 Jan 2026 12:10:33 +0200 Subject: [PATCH 5/7] moved debug error persistence to actor system context --- .../server/actors/ActorSystemContext.java | 15 ++++++++++- ...CalculatedFieldEntityMessageProcessor.java | 27 +++++-------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index d3c26e2640..d344a9d92d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -41,6 +41,7 @@ import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; import org.thingsboard.script.api.js.JsInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldException; import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.actors.tenant.DebugTbRateLimits; import org.thingsboard.server.cache.limits.RateLimitService; @@ -97,8 +98,8 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleNodeStateService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; @@ -824,6 +825,18 @@ public class ActorSystemContext { Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); } + public void persistCalculatedFieldDebugError(CalculatedFieldException cfe) { + String message; + if (cfe.getErrorMessage() != null) { + message = cfe.getErrorMessage(); + } else if (cfe.getCause() != null) { + message = cfe.getCause().getMessage(); + } else { + message = "N/A"; + } + persistCalculatedFieldDebugEvent(cfe.getCtx().getTenantId(), cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + } + public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, Map arguments, UUID tbMsgId, TbMsgType tbMsgType, String result, String errorMessage) { if (checkLimits(tenantId)) { try { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index b95beb81a1..4f59628656 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -66,7 +66,6 @@ import java.util.stream.Collectors; import static org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry.getValueForTsRecord; - /** * @author Andrew Shvayka */ @@ -135,8 +134,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } catch (Exception e) { if (e instanceof CalculatedFieldException cfe) { - persistDebugErrorIfEnabled(cfe, msg.getCallback()); - return; + throw cfe; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -202,8 +200,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } catch (Exception e) { if (e instanceof CalculatedFieldException cfe) { - persistDebugErrorIfEnabled(cfe, callback); - return; + throw cfe; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -228,7 +225,10 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } catch (Exception e) { if (e instanceof CalculatedFieldException cfe) { - persistDebugErrorIfEnabled(cfe, callback); + if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { + systemContext.persistCalculatedFieldDebugError(cfe); + } + callback.onSuccess(); return; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); @@ -491,19 +491,4 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM return null; } - private void persistDebugErrorIfEnabled(CalculatedFieldException cfe, TbCallback callback) { - if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { - String message; - if (cfe.getErrorMessage() != null) { - message = cfe.getErrorMessage(); - } else if (cfe.getCause() != null) { - message = cfe.getCause().getMessage(); - } else { - message = "N/A"; - } - systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); - } - callback.onSuccess(); - } - } From 3e0628099f9cf5118e05439a7518c2a9fb3bb093 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 26 Jan 2026 12:15:59 +0200 Subject: [PATCH 6/7] call error debug persistence in cf actors --- .../calculatedField/AbstractCalculatedFieldActor.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java index 9f82b536fd..29c7fe6587 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java @@ -41,15 +41,7 @@ public abstract class AbstractCalculatedFieldActor extends ContextAwareActor { return doProcessCfMsg(cfm); } catch (CalculatedFieldException cfe) { if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { - String message; - if (cfe.getErrorMessage() != null) { - message = cfe.getErrorMessage(); - } else if (cfe.getCause() != null) { - message = cfe.getCause().getMessage(); - } else { - message = "N/A"; - } - systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + systemContext.persistCalculatedFieldDebugError(cfe); } cause = cfe.getCause(); } catch (Exception e) { From 22d6a146002a056d54f57d2354835b77217358a7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 26 Jan 2026 14:46:14 +0200 Subject: [PATCH 7/7] Fix license header for DefaultSystemSecurityServiceTest --- .../security/system/DefaultSystemSecurityServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java index 05e51177af..0933dfd942 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * 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.