committed by
GitHub
55 changed files with 1719 additions and 118 deletions
@ -0,0 +1,105 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.apikey; |
|||
|
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Component; |
|||
import org.thingsboard.server.common.data.EdgeUtils; |
|||
import org.thingsboard.server.common.data.edge.Edge; |
|||
import org.thingsboard.server.common.data.edge.EdgeEvent; |
|||
import org.thingsboard.server.common.data.edge.EdgeEventType; |
|||
import org.thingsboard.server.common.data.id.ApiKeyId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.pat.ApiKey; |
|||
import org.thingsboard.server.exception.DataValidationException; |
|||
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; |
|||
import org.thingsboard.server.gen.edge.v1.DownlinkMsg; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeVersion; |
|||
import org.thingsboard.server.gen.edge.v1.UpdateMsgType; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
@Component |
|||
@TbCoreComponent |
|||
public class ApiKeyEdgeProcessor extends BaseApiKeyProcessor implements ApiKeyProcessor { |
|||
|
|||
@Override |
|||
public ListenableFuture<Void> processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg) { |
|||
ApiKeyId apiKeyId = new ApiKeyId(new UUID(apiKeyUpdateMsg.getIdMSB(), apiKeyUpdateMsg.getIdLSB())); |
|||
try { |
|||
edgeSynchronizationManager.getEdgeId().set(edge.getId()); |
|||
|
|||
return switch (apiKeyUpdateMsg.getMsgType()) { |
|||
case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> { |
|||
boolean created = saveOrUpdateApiKey(tenantId, apiKeyId, apiKeyUpdateMsg); |
|||
if (created) { |
|||
ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); |
|||
if (apiKey != null) { |
|||
pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_CREATED); |
|||
} |
|||
} |
|||
yield Futures.immediateFuture(null); |
|||
} |
|||
case ENTITY_DELETED_RPC_MESSAGE -> { |
|||
deleteApiKey(tenantId, edge, apiKeyId); |
|||
yield Futures.immediateFuture(null); |
|||
} |
|||
default -> handleUnsupportedMsgType(apiKeyUpdateMsg.getMsgType()); |
|||
}; |
|||
} catch (DataValidationException e) { |
|||
return Futures.immediateFailedFuture(e); |
|||
} finally { |
|||
edgeSynchronizationManager.getEdgeId().remove(); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { |
|||
ApiKeyId apiKeyId = new ApiKeyId(edgeEvent.getEntityId()); |
|||
switch (edgeEvent.getAction()) { |
|||
case ADDED, UPDATED -> { |
|||
ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(edgeEvent.getTenantId(), apiKeyId); |
|||
if (apiKey != null) { |
|||
UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); |
|||
ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyUpdatedMsg(msgType, apiKey); |
|||
return DownlinkMsg.newBuilder() |
|||
.setDownlinkMsgId(EdgeUtils.nextPositiveInt()) |
|||
.addApiKeyUpdateMsg(apiKeyUpdateMsg) |
|||
.build(); |
|||
} |
|||
} |
|||
case DELETED -> { |
|||
ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyDeleteMsg(apiKeyId); |
|||
return DownlinkMsg.newBuilder() |
|||
.setDownlinkMsgId(EdgeUtils.nextPositiveInt()) |
|||
.addApiKeyUpdateMsg(apiKeyUpdateMsg) |
|||
.build(); |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
@Override |
|||
public EdgeEventType getEdgeEventType() { |
|||
return EdgeEventType.API_KEY; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.apikey; |
|||
|
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import org.thingsboard.server.common.data.edge.Edge; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; |
|||
|
|||
public interface ApiKeyProcessor extends EdgeProcessor { |
|||
|
|||
ListenableFuture<Void> processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg); |
|||
|
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.apikey; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.edge.Edge; |
|||
import org.thingsboard.server.common.data.id.ApiKeyId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.data.pat.ApiKey; |
|||
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
@Slf4j |
|||
public abstract class BaseApiKeyProcessor extends BaseEdgeProcessor { |
|||
|
|||
protected boolean saveOrUpdateApiKey(TenantId tenantId, ApiKeyId apiKeyId, ApiKeyUpdateMsg apiKeyUpdateMsg) { |
|||
boolean isCreated = false; |
|||
try { |
|||
ApiKey apiKey = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); |
|||
if (apiKey == null) { |
|||
throw new RuntimeException("[{" + tenantId + "}] apiKeyUpdateMsg {" + apiKeyUpdateMsg + " } cannot be converted to apiKey"); |
|||
} |
|||
|
|||
ApiKey existingApiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); |
|||
if (existingApiKey == null) { |
|||
apiKey.setCreatedTime(Uuids.unixTimestamp(apiKeyId.getId())); |
|||
isCreated = true; |
|||
} |
|||
|
|||
apiKey.setId(apiKeyId); |
|||
edgeCtx.getApiKeyService().saveApiKey(tenantId, apiKey, apiKey.getValue(), false); |
|||
} catch (Exception e) { |
|||
log.error("[{}] Failed to process apiKey update msg [{}]", tenantId, apiKeyUpdateMsg, e); |
|||
throw e; |
|||
} |
|||
return isCreated; |
|||
} |
|||
|
|||
protected void deleteApiKey(TenantId tenantId, Edge edge, ApiKeyId apiKeyId) { |
|||
ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); |
|||
if (apiKey != null) { |
|||
edgeCtx.getApiKeyService().deleteApiKey(tenantId, apiKey, false); |
|||
pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_DELETED); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,238 @@ |
|||
/** |
|||
* 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.edge; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import com.google.protobuf.AbstractMessage; |
|||
import com.google.protobuf.InvalidProtocolBufferException; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.User; |
|||
import org.thingsboard.server.common.data.id.ApiKeyId; |
|||
import org.thingsboard.server.common.data.pat.ApiKey; |
|||
import org.thingsboard.server.common.data.pat.ApiKeyInfo; |
|||
import org.thingsboard.server.common.data.security.Authority; |
|||
import org.thingsboard.server.dao.pat.ApiKeyService; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UpdateMsgType; |
|||
import org.thingsboard.server.gen.edge.v1.UplinkMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.awaitility.Awaitility.await; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
import static org.thingsboard.server.gen.edge.v1.UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; |
|||
|
|||
@DaoSqlTest |
|||
public class ApiKeyEdgeTest extends AbstractEdgeTest { |
|||
|
|||
@Autowired |
|||
private ApiKeyService apiKeyService; |
|||
|
|||
private static final String DEFAULT_API_KEY_DESCRIPTION = "Edge Test ApiKey"; |
|||
private static final String UPDATED_API_KEY_DESCRIPTION = "Updated Edge Test ApiKey"; |
|||
|
|||
@Test |
|||
public void testApiKey_create_update_delete_fromCloud() throws Exception { |
|||
// create ApiKey
|
|||
ApiKeyInfo apiKeyInfo = createSimpleApiKeyInfo(DEFAULT_API_KEY_DESCRIPTION); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
ApiKey savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
AbstractMessage latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); |
|||
ApiKeyUpdateMsg apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); |
|||
Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); |
|||
ApiKey apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); |
|||
Assert.assertNotNull(apiKeyFromMsg); |
|||
|
|||
Assert.assertEquals(DEFAULT_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); |
|||
Assert.assertEquals(savedApiKey.getTenantId(), apiKeyFromMsg.getTenantId()); |
|||
|
|||
// update ApiKey
|
|||
edgeImitator.expectMessageAmount(1); |
|||
savedApiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); |
|||
savedApiKey = doPost("/api/apiKey", new ApiKeyInfo(savedApiKey), ApiKey.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); |
|||
apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; |
|||
apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); |
|||
Assert.assertNotNull(apiKeyFromMsg); |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(UPDATED_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); |
|||
|
|||
// delete ApiKey
|
|||
edgeImitator.expectMessageAmount(1); |
|||
doDelete("/api/apiKey/" + savedApiKey.getUuidId()) |
|||
.andExpect(status().isOk()); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); |
|||
apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); |
|||
Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); |
|||
} |
|||
|
|||
@Test |
|||
public void testApiKey_create_update_delete_toCloud() throws Exception { |
|||
// create
|
|||
ApiKey apiKey = createSimpleApiKey(DEFAULT_API_KEY_DESCRIPTION); |
|||
UUID uuid = Uuids.timeBased(); |
|||
UplinkMsg uplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); |
|||
|
|||
checkApiKeyOnCloud(uplinkMsg, uuid, apiKey.getDescription()); |
|||
|
|||
// update
|
|||
apiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); |
|||
UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); |
|||
|
|||
checkApiKeyOnCloud(updatedUplinkMsg, uuid, apiKey.getDescription()); |
|||
|
|||
// delete
|
|||
UplinkMsg deleteUplinkMsg = getDeleteUplinkMsg(uuid); |
|||
edgeImitator.expectResponsesAmount(1); |
|||
edgeImitator.sendUplinkMsg(deleteUplinkMsg); |
|||
Assert.assertTrue(edgeImitator.waitForResponses()); |
|||
|
|||
ApiKeyId apiKeyId = new ApiKeyId(uuid); |
|||
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> |
|||
Assert.assertNull(apiKeyService.findApiKeyById(tenantId, apiKeyId)) |
|||
); |
|||
} |
|||
|
|||
@Test |
|||
public void testApiKey_pushedDuringUserSync() throws Exception { |
|||
// create tenant admin user - expect 3 messages: 1 UserUpdateMsg + 2 UserCredentialsUpdateMsg
|
|||
User user = new User(); |
|||
user.setAuthority(Authority.TENANT_ADMIN); |
|||
user.setTenantId(tenantId); |
|||
user.setEmail("apiKeyTestUser@thingsboard.org"); |
|||
user.setFirstName("ApiKey"); |
|||
user.setLastName("TestUser"); |
|||
|
|||
edgeImitator.expectMessageAmount(3); |
|||
User savedUser = createUser(user, "tenant"); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); |
|||
Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); |
|||
|
|||
// create API key for this user - expect 1 ApiKeyUpdateMsg
|
|||
ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); |
|||
apiKeyInfo.setTenantId(tenantId); |
|||
apiKeyInfo.setUserId(savedUser.getId()); |
|||
apiKeyInfo.setDescription("Test API Key for user sync"); |
|||
apiKeyInfo.setEnabled(true); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
doPost("/api/apiKey", apiKeyInfo, ApiKey.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); |
|||
|
|||
// update user - expect 3 messages: UserUpdateMsg + UserCredentialsUpdateMsg + ApiKeyUpdateMsg
|
|||
savedUser.setLastName("UpdatedLastName"); |
|||
edgeImitator.expectMessageAmount(3); |
|||
doPost("/api/user", savedUser, User.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); |
|||
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); |
|||
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); |
|||
|
|||
Optional<ApiKeyUpdateMsg> apiKeyUpdateMsgOpt = edgeImitator.findMessageByType(ApiKeyUpdateMsg.class); |
|||
Assert.assertTrue(apiKeyUpdateMsgOpt.isPresent()); |
|||
ApiKeyUpdateMsg apiKeyUpdateMsg = apiKeyUpdateMsgOpt.get(); |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); |
|||
} |
|||
|
|||
private ApiKeyInfo createSimpleApiKeyInfo(String description) { |
|||
ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); |
|||
apiKeyInfo.setTenantId(tenantId); |
|||
apiKeyInfo.setUserId(tenantAdminUserId); |
|||
apiKeyInfo.setDescription(description); |
|||
apiKeyInfo.setEnabled(true); |
|||
return apiKeyInfo; |
|||
} |
|||
|
|||
private ApiKey createSimpleApiKey(String description) { |
|||
ApiKey apiKey = new ApiKey(); |
|||
apiKey.setTenantId(tenantId); |
|||
apiKey.setUserId(tenantAdminUserId); |
|||
apiKey.setDescription(description); |
|||
apiKey.setEnabled(true); |
|||
apiKey.setValue("test-api-key-value-" + UUID.randomUUID()); |
|||
return apiKey; |
|||
} |
|||
|
|||
private UplinkMsg getDeleteUplinkMsg(UUID uuid) throws InvalidProtocolBufferException { |
|||
UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); |
|||
ApiKeyUpdateMsg.Builder apiKeyDeleteMsgBuilder = ApiKeyUpdateMsg.newBuilder(); |
|||
apiKeyDeleteMsgBuilder.setMsgType(ENTITY_DELETED_RPC_MESSAGE); |
|||
apiKeyDeleteMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); |
|||
apiKeyDeleteMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); |
|||
testAutoGeneratedCodeByProtobuf(apiKeyDeleteMsgBuilder); |
|||
|
|||
upLinkMsgBuilder.addApiKeyUpdateMsg(apiKeyDeleteMsgBuilder.build()); |
|||
testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); |
|||
|
|||
return upLinkMsgBuilder.build(); |
|||
} |
|||
|
|||
private UplinkMsg getUplinkMsg(UUID uuid, ApiKey apiKey, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { |
|||
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); |
|||
ApiKeyUpdateMsg.Builder apiKeyUpdateMsgBuilder = ApiKeyUpdateMsg.newBuilder(); |
|||
apiKeyUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); |
|||
apiKeyUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); |
|||
apiKeyUpdateMsgBuilder.setEntity(JacksonUtil.toString(apiKey)); |
|||
apiKeyUpdateMsgBuilder.setMsgType(updateMsgType); |
|||
testAutoGeneratedCodeByProtobuf(apiKeyUpdateMsgBuilder); |
|||
uplinkMsgBuilder.addApiKeyUpdateMsg(apiKeyUpdateMsgBuilder.build()); |
|||
|
|||
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); |
|||
|
|||
return uplinkMsgBuilder.build(); |
|||
} |
|||
|
|||
private void checkApiKeyOnCloud(UplinkMsg uplinkMsg, UUID uuid, String description) throws Exception { |
|||
edgeImitator.expectResponsesAmount(1); |
|||
edgeImitator.sendUplinkMsg(uplinkMsg); |
|||
|
|||
Assert.assertTrue(edgeImitator.waitForResponses()); |
|||
|
|||
UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); |
|||
Assert.assertTrue(latestResponseMsg.getSuccess()); |
|||
|
|||
ApiKey apiKey = apiKeyService.findApiKeyById(tenantId, new ApiKeyId(uuid)); |
|||
Assert.assertNotNull(apiKey); |
|||
Assert.assertEquals(description, apiKey.getDescription()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,272 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc; |
|||
|
|||
import io.grpc.ManagedChannel; |
|||
import io.grpc.Server; |
|||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; |
|||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; |
|||
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; |
|||
import org.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.jce.provider.BouncyCastleProvider; |
|||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter; |
|||
import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.bouncycastle.util.io.pem.PemObject; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.EnumSource; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.controller.AbstractWebTest; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; |
|||
|
|||
import java.io.ByteArrayInputStream; |
|||
import java.math.BigInteger; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.PrivateKey; |
|||
import java.security.Security; |
|||
import java.security.cert.X509Certificate; |
|||
import java.security.spec.ECGenParameterSpec; |
|||
import java.util.ArrayList; |
|||
import java.util.Date; |
|||
import java.util.List; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
/** |
|||
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. |
|||
* <p> |
|||
* Covers: |
|||
* 1. Separate cert and key PEM inputs |
|||
* 2. Combined PEM (cert + key in one file) |
|||
* 3. Encrypted private key with password |
|||
* 4. Missing key in combined PEM → error |
|||
* <p> |
|||
* Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384. |
|||
*/ |
|||
class EdgeGrpcSslTest { |
|||
|
|||
static { |
|||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { |
|||
Security.addProvider(new BouncyCastleProvider()); |
|||
} |
|||
} |
|||
|
|||
enum KeyType { |
|||
RSA_2048("RSA", 2048, null, "SHA256withRSA"), |
|||
RSA_4096("RSA", 4096, null, "SHA256withRSA"), |
|||
EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"), |
|||
EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); |
|||
|
|||
final String algorithm; |
|||
final int size; |
|||
final String curve; |
|||
final String sigAlg; |
|||
|
|||
KeyType(String algorithm, int size, String curve, String sigAlg) { |
|||
this.algorithm = algorithm; |
|||
this.size = size; |
|||
this.curve = curve; |
|||
this.sigAlg = sigAlg; |
|||
} |
|||
|
|||
KeyPair generateKeyPair() throws Exception { |
|||
KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm); |
|||
if (curve != null) { |
|||
kpg.initialize(new ECGenParameterSpec(curve)); |
|||
} else { |
|||
kpg.initialize(size); |
|||
} |
|||
return kpg.generateKeyPair(); |
|||
} |
|||
} |
|||
|
|||
private final List<Path> tempFiles = new ArrayList<>(); |
|||
private Server server; |
|||
private ManagedChannel channel; |
|||
|
|||
@AfterEach |
|||
void cleanup() throws Exception { |
|||
if (channel != null) { |
|||
channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); |
|||
} |
|||
if (server != null) { |
|||
server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); |
|||
} |
|||
for (Path p : tempFiles) { |
|||
Files.deleteIfExists(p); |
|||
} |
|||
} |
|||
|
|||
@ParameterizedTest(name = "separateCertAndKey_{0}") |
|||
@EnumSource(KeyType.class) |
|||
void separateCertAndKey(KeyType keyType) throws Exception { |
|||
KeyPair kp = keyType.generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
|||
|
|||
Path certFile = writeTempPem("cert", cert); |
|||
Path keyFile = writeTempPem("key", kp.getPrivate()); |
|||
|
|||
server = startServer(certFile.toString(), keyFile.toString(), null); |
|||
assertTlsConnectivity(cert); |
|||
} |
|||
|
|||
@ParameterizedTest(name = "combinedPemWithCertAndKey_{0}") |
|||
@EnumSource(KeyType.class) |
|||
void combinedPemWithCertAndKey(KeyType keyType) throws Exception { |
|||
KeyPair kp = keyType.generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
|||
|
|||
Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); |
|||
|
|||
server = startServer(combinedFile.toString(), "", null); |
|||
assertTlsConnectivity(cert); |
|||
} |
|||
|
|||
// RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY),
|
|||
// which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials
|
|||
// to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null".
|
|||
@ParameterizedTest(name = "encryptedPrivateKey_{0}") |
|||
@EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"}) |
|||
void encryptedPrivateKey(KeyType keyType) throws Exception { |
|||
KeyPair kp = keyType.generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
|||
String password = "test-password"; |
|||
|
|||
Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); |
|||
|
|||
server = startServer(combinedFile.toString(), "", password); |
|||
assertTlsConnectivity(cert); |
|||
} |
|||
|
|||
@ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}") |
|||
@EnumSource(KeyType.class) |
|||
void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception { |
|||
KeyPair kp = keyType.generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); |
|||
|
|||
Path certOnlyFile = writeTempPem("cert-only", cert); |
|||
|
|||
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) |
|||
.isInstanceOf(IllegalArgumentException.class); |
|||
} |
|||
|
|||
// --- Server startup using production EdgeGrpcService.setupSsl() ---
|
|||
|
|||
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { |
|||
EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); |
|||
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); |
|||
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); |
|||
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); |
|||
|
|||
NettyServerBuilder builder = NettyServerBuilder.forPort(0) |
|||
.addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); |
|||
|
|||
edgeGrpcService.setupSsl(builder); |
|||
|
|||
return builder.build().start(); |
|||
} |
|||
|
|||
private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { |
|||
String certPem = toPem(trustedCert); |
|||
var clientSsl = GrpcSslContexts.forClient() |
|||
.trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) |
|||
.build(); |
|||
|
|||
channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) |
|||
.sslContext(clientSsl) |
|||
.build(); |
|||
|
|||
channel.getState(true); // trigger connection attempt
|
|||
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(50, TimeUnit.MILLISECONDS) |
|||
.untilAsserted(() -> { |
|||
var state = channel.getState(false); |
|||
if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { |
|||
throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); |
|||
} |
|||
assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY); |
|||
}); |
|||
} |
|||
|
|||
// --- Cert/key generation ---
|
|||
|
|||
private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception { |
|||
X500Name subject = new X500Name("CN=localhost"); |
|||
Date now = new Date(); |
|||
return new JcaX509CertificateConverter().getCertificate( |
|||
new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.ONE, now, |
|||
new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), |
|||
subject, kp.getPublic()) |
|||
.build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate()))); |
|||
} |
|||
|
|||
// --- PEM file helpers ---
|
|||
|
|||
private String toPem(Object obj) throws Exception { |
|||
java.io.StringWriter sw = new java.io.StringWriter(); |
|||
try (JcaPEMWriter w = new JcaPEMWriter(sw)) { |
|||
w.writeObject(obj); |
|||
} |
|||
return sw.toString(); |
|||
} |
|||
|
|||
private Path writeTempPem(String prefix, Object... objects) throws Exception { |
|||
Path p = Files.createTempFile(prefix + "-", ".pem"); |
|||
tempFiles.add(p); |
|||
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { |
|||
for (Object o : objects) { |
|||
w.writeObject(toPkcs8IfKey(o)); |
|||
} |
|||
} |
|||
return p; |
|||
} |
|||
|
|||
private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception { |
|||
Path p = Files.createTempFile(prefix + "-", ".pem"); |
|||
tempFiles.add(p); |
|||
var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC") |
|||
.setProvider(BouncyCastleProvider.PROVIDER_NAME) |
|||
.build(password.toCharArray()); |
|||
try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { |
|||
for (Object o : objects) { |
|||
if (o instanceof PrivateKey) { |
|||
w.writeObject(o, encryptor); |
|||
} else { |
|||
w.writeObject(o); |
|||
} |
|||
} |
|||
} |
|||
return p; |
|||
} |
|||
|
|||
private Object toPkcs8IfKey(Object o) { |
|||
if (o instanceof PrivateKey pk) { |
|||
return new PemObject("PRIVATE KEY", pk.getEncoded()); |
|||
} |
|||
return o; |
|||
} |
|||
} |
|||
@ -0,0 +1,144 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.ttl; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
|||
import org.thingsboard.server.dao.notification.NotificationRequestDao; |
|||
import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; |
|||
import org.thingsboard.server.dao.tenant.TenantService; |
|||
import org.thingsboard.server.queue.discovery.PartitionService; |
|||
|
|||
import java.util.List; |
|||
import java.util.UUID; |
|||
|
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyInt; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class NotificationsCleanUpServiceTest { |
|||
|
|||
@Mock |
|||
private PartitionService partitionService; |
|||
@Mock |
|||
private SqlPartitioningRepository partitioningRepository; |
|||
@Mock |
|||
private NotificationRequestDao notificationRequestDao; |
|||
@Mock |
|||
private TenantService tenantService; |
|||
|
|||
private NotificationsCleanUpService cleanUpService; |
|||
|
|||
private static final int BATCH_SIZE = 3; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
cleanUpService = new NotificationsCleanUpService(partitionService, partitioningRepository, notificationRequestDao, tenantService); |
|||
ReflectionTestUtils.setField(cleanUpService, "ttlInSec", 2592000L); |
|||
ReflectionTestUtils.setField(cleanUpService, "partitionSizeInHours", 168); |
|||
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); |
|||
} |
|||
|
|||
@Test |
|||
public void testBatchLoopCallsDaoMultipleTimes() { |
|||
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
|||
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); |
|||
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
|||
.thenReturn(System.currentTimeMillis()); |
|||
|
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
when(tenantService.findTenantsIds(any())) |
|||
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); |
|||
|
|||
// Sysadmin: returns 3 (full batch), then 1 (partial) -> 2 calls
|
|||
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(BATCH_SIZE) |
|||
.thenReturn(1); |
|||
// Tenant: returns 3, 3, 0 -> 3 calls
|
|||
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(BATCH_SIZE) |
|||
.thenReturn(BATCH_SIZE) |
|||
.thenReturn(0); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(notificationRequestDao, times(2)) |
|||
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); |
|||
verify(notificationRequestDao, times(3)) |
|||
.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); |
|||
} |
|||
|
|||
@Test |
|||
public void testSkipsTenantNotOnMyPartition() { |
|||
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
|||
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); |
|||
when(partitionService.resolve(any(), eq(TenantId.SYS_TENANT_ID), eq(TenantId.SYS_TENANT_ID))) |
|||
.thenReturn(myPartition); |
|||
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
|||
.thenReturn(System.currentTimeMillis()); |
|||
|
|||
// Sysadmin: no records
|
|||
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(0); |
|||
|
|||
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); |
|||
when(tenantService.findTenantsIds(any())) |
|||
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); |
|||
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); |
|||
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); |
|||
|
|||
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(0); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); |
|||
verify(notificationRequestDao, never()).removeByTenantIdAndCreatedTimeBeforeBatch(eq(otherTenant), anyLong(), anyInt()); |
|||
} |
|||
|
|||
@Test |
|||
public void testNoPartitionsDropped_stillCleansUpRequests() { |
|||
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
|||
when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); |
|||
when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) |
|||
.thenReturn(0L); |
|||
|
|||
when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(0); |
|||
when(tenantService.findTenantsIds(any())) |
|||
.thenReturn(new PageData<>(List.of(), 0, 0, false)); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.ttl.rpc; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.data.TenantProfile; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; |
|||
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; |
|||
import org.thingsboard.server.dao.rpc.RpcDao; |
|||
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; |
|||
import org.thingsboard.server.dao.tenant.TenantService; |
|||
import org.thingsboard.server.queue.discovery.PartitionService; |
|||
|
|||
import java.util.List; |
|||
import java.util.UUID; |
|||
|
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyInt; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class RpcCleanUpServiceTest { |
|||
|
|||
@Mock |
|||
private PartitionService partitionService; |
|||
@Mock |
|||
private RpcDao rpcDao; |
|||
@Mock |
|||
private TenantService tenantService; |
|||
@Mock |
|||
private TbTenantProfileCache tenantProfileCache; |
|||
|
|||
private RpcCleanUpService cleanUpService; |
|||
|
|||
private static final int BATCH_SIZE = 3; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
cleanUpService = new RpcCleanUpService(tenantService, partitionService, tenantProfileCache, rpcDao); |
|||
ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); |
|||
} |
|||
|
|||
@Test |
|||
public void testBatchLoopCallsDaoMultipleTimes() { |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
setupTenant(tenantId, 7); |
|||
|
|||
// Returns 3 (full batch), 3 (full batch), 1 (partial) -> 3 calls
|
|||
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(BATCH_SIZE) |
|||
.thenReturn(BATCH_SIZE) |
|||
.thenReturn(1); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(rpcDao, times(3)).deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); |
|||
} |
|||
|
|||
@Test |
|||
public void testSkipsTenantNotOnMyPartition() { |
|||
TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); |
|||
|
|||
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
|||
TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); |
|||
|
|||
when(tenantService.findTenantsIds(any())) |
|||
.thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); |
|||
when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); |
|||
when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); |
|||
|
|||
setupTenantProfile(myTenant, 7); |
|||
when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) |
|||
.thenReturn(0); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(rpcDao).deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); |
|||
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(eq(otherTenant), anyLong(), anyInt()); |
|||
} |
|||
|
|||
@Test |
|||
public void testSkipsTenantWithZeroTtl() { |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
setupTenant(tenantId, 0); |
|||
|
|||
cleanUpService.cleanUp(); |
|||
|
|||
verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(any(), anyLong(), anyInt()); |
|||
} |
|||
|
|||
private void setupTenant(TenantId tenantId, int rpcTtlDays) { |
|||
TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); |
|||
when(partitionService.resolve(any(), eq(tenantId), eq(tenantId))).thenReturn(myPartition); |
|||
when(tenantService.findTenantsIds(any())) |
|||
.thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); |
|||
setupTenantProfile(tenantId, rpcTtlDays); |
|||
} |
|||
|
|||
private void setupTenantProfile(TenantId tenantId, int rpcTtlDays) { |
|||
TenantProfile profile = new TenantProfile(); |
|||
TenantProfileData profileData = new TenantProfileData(); |
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
config.setRpcTtlDays(rpcTtlDays); |
|||
profileData.setConfiguration(config); |
|||
profile.setProfileData(profileData); |
|||
when(tenantProfileCache.get(tenantId)).thenReturn(profile); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.sql.notification; |
|||
|
|||
import org.junit.After; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.server.common.data.id.NotificationRequestId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.notification.NotificationRequest; |
|||
import org.thingsboard.server.common.data.notification.NotificationRequestStatus; |
|||
import org.thingsboard.server.dao.AbstractJpaDaoTest; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
public class JpaNotificationRequestDaoTest extends AbstractJpaDaoTest { |
|||
|
|||
@Autowired |
|||
JpaNotificationRequestDao notificationRequestDao; |
|||
|
|||
private final List<NotificationRequest> createdRequests = new ArrayList<>(); |
|||
|
|||
@After |
|||
public void tearDown() { |
|||
for (NotificationRequest request : createdRequests) { |
|||
notificationRequestDao.removeById(request.getTenantId(), request.getId().getId()); |
|||
} |
|||
createdRequests.clear(); |
|||
} |
|||
|
|||
@Test |
|||
public void testBatchDeletion() { |
|||
TenantId sysTenantId = TenantId.SYS_TENANT_ID; |
|||
long now = System.currentTimeMillis(); |
|||
long oldTimestamp = now - TimeUnit.DAYS.toMillis(30); |
|||
|
|||
NotificationRequest oldRequest1 = createNotificationRequest(sysTenantId, oldTimestamp); |
|||
notificationRequestDao.save(sysTenantId, oldRequest1); |
|||
|
|||
NotificationRequest oldRequest2 = createNotificationRequest(sysTenantId, oldTimestamp); |
|||
notificationRequestDao.save(sysTenantId, oldRequest2); |
|||
|
|||
NotificationRequest freshRequest = createNotificationRequest(sysTenantId, now); |
|||
notificationRequestDao.save(sysTenantId, freshRequest); |
|||
|
|||
TenantId tenant2Id = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); |
|||
NotificationRequest tenant2Request = createNotificationRequest(tenant2Id, oldTimestamp); |
|||
notificationRequestDao.save(tenant2Id, tenant2Request); |
|||
|
|||
int batchSize = 10_000; |
|||
|
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, oldTimestamp - 1, batchSize)).isEqualTo(0); |
|||
|
|||
long expirationTime = now - TimeUnit.DAYS.toMillis(15); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, expirationTime, batchSize)).isEqualTo(2); |
|||
|
|||
assertThat(notificationRequestDao.findById(sysTenantId, freshRequest.getId().getId())).isNotNull(); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2Id, now + 1, batchSize)).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void testBatchDeletionWithSmallBatchSize() { |
|||
TenantId tenantId = TenantId.SYS_TENANT_ID; |
|||
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); |
|||
|
|||
for (int i = 0; i < 10; i++) { |
|||
NotificationRequest request = createNotificationRequest(tenantId, oldTimestamp); |
|||
notificationRequestDao.save(tenantId, request); |
|||
} |
|||
|
|||
int batchSize = 3; |
|||
long expirationTime = System.currentTimeMillis(); |
|||
|
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(1); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(0); |
|||
} |
|||
|
|||
@Test |
|||
public void testBatchDeletionIsolationBetweenTenants() { |
|||
TenantId tenant1 = TenantId.SYS_TENANT_ID; |
|||
TenantId tenant2 = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); |
|||
long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); |
|||
|
|||
for (int i = 0; i < 5; i++) { |
|||
NotificationRequest request = createNotificationRequest(tenant1, oldTimestamp); |
|||
notificationRequestDao.save(tenant1, request); |
|||
} |
|||
|
|||
for (int i = 0; i < 3; i++) { |
|||
NotificationRequest request = createNotificationRequest(tenant2, oldTimestamp); |
|||
notificationRequestDao.save(tenant2, request); |
|||
} |
|||
|
|||
int batchSize = 10_000; |
|||
long expirationTime = System.currentTimeMillis(); |
|||
|
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant1, expirationTime, batchSize)).isEqualTo(5); |
|||
assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2, expirationTime, batchSize)).isEqualTo(3); |
|||
} |
|||
|
|||
private NotificationRequest createNotificationRequest(TenantId tenantId, long createdTime) { |
|||
NotificationRequest request = new NotificationRequest(); |
|||
request.setId(new NotificationRequestId(UUID.randomUUID())); |
|||
request.setTenantId(tenantId); |
|||
request.setCreatedTime(createdTime); |
|||
request.setTargets(List.of(UUID.randomUUID())); |
|||
request.setStatus(NotificationRequestStatus.SENT); |
|||
createdRequests.add(request); |
|||
return request; |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue