51 changed files with 1114 additions and 342 deletions
@ -0,0 +1,155 @@ |
|||
/** |
|||
* 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 org.junit.After; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.thingsboard.edge.exception.EdgeFeatureDisabledException; |
|||
import org.thingsboard.server.common.data.ApiUsageRecordKey; |
|||
import org.thingsboard.server.common.data.ApiUsageStateValue; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.stats.TbApiUsageReportClient; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; |
|||
import org.thingsboard.server.edge.imitator.EdgeImitator; |
|||
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder; |
|||
|
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
@DaoSqlTest |
|||
@TestPropertySource(properties = { |
|||
"usage.stats.report.enabled=true", |
|||
"usage.stats.report.interval=2", |
|||
"usage.stats.report.urgent_interval=1" |
|||
}) |
|||
public class EdgeApiUsageDisabledTest extends AbstractEdgeTest { |
|||
|
|||
private static final int MAX_EDGE_EVENTS = 1; |
|||
|
|||
@Autowired |
|||
private ApiUsageStateService apiUsageStateService; |
|||
|
|||
@Autowired |
|||
private TbApiUsageReportClient apiUsageReportClient; |
|||
|
|||
@Autowired |
|||
private EdgeSessionsHolder edgeSessionsHolder; |
|||
|
|||
@After |
|||
public void restoreEdgeLimit() { |
|||
try { |
|||
loginSysAdmin(); |
|||
updateDefaultTenantProfileConfig(cfg -> cfg.setMaxEdgeEvents(0)); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
|
|||
@Test |
|||
public void testLiveSessionForceClosedWhenEdgeStateDisabled() throws Exception { |
|||
await().atMost(10, TimeUnit.SECONDS).until(() -> sessionConnected(tenantId)); |
|||
|
|||
loginSysAdmin(); |
|||
updateDefaultTenantProfileConfig(cfg -> cfg.setMaxEdgeEvents(MAX_EDGE_EVENTS)); |
|||
loginTenantAdmin(); |
|||
|
|||
for (int i = 0; i < MAX_EDGE_EVENTS + 5; i++) { |
|||
apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.EDGE_EVENT_COUNT); |
|||
} |
|||
|
|||
await().atMost(15, TimeUnit.SECONDS).until(() -> apiUsageStateService.findTenantApiUsageState(tenantId).getEdgeState() == ApiUsageStateValue.DISABLED); |
|||
|
|||
await().atMost(10, TimeUnit.SECONDS).until(() -> !sessionConnected(tenantId)); |
|||
} |
|||
|
|||
@Test |
|||
public void testReconnectRejectedWhenEdgeStateDisabled() throws Exception { |
|||
await().atMost(10, TimeUnit.SECONDS).until(() -> sessionConnected(tenantId)); |
|||
|
|||
loginSysAdmin(); |
|||
updateDefaultTenantProfileConfig(cfg -> cfg.setMaxEdgeEvents(MAX_EDGE_EVENTS)); |
|||
loginTenantAdmin(); |
|||
|
|||
for (int i = 0; i < MAX_EDGE_EVENTS + 5; i++) { |
|||
apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.EDGE_EVENT_COUNT); |
|||
} |
|||
|
|||
await().atMost(15, TimeUnit.SECONDS).until(() -> !sessionConnected(tenantId)); |
|||
|
|||
EdgeImitator rejectedImitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); |
|||
rejectedImitator.connect(); |
|||
|
|||
await().atMost(10, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
Exception closeException = rejectedImitator.getCloseException(); |
|||
Assert.assertNotNull("Imitator must receive a connection-rejected callback", closeException); |
|||
Assert.assertTrue("Expected EdgeFeatureDisabledException, got: " + closeException, closeException instanceof EdgeFeatureDisabledException); |
|||
}); |
|||
Assert.assertFalse("Edge session must not be admitted when edge feature is disabled", |
|||
sessionConnected(tenantId)); |
|||
|
|||
try { |
|||
rejectedImitator.disconnect(); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
|
|||
@Test |
|||
public void testReconnectAdmittedAfterEdgeStateReEnabled() throws Exception { |
|||
await().atMost(10, TimeUnit.SECONDS).until(() -> sessionConnected(tenantId)); |
|||
|
|||
loginSysAdmin(); |
|||
updateDefaultTenantProfileConfig(cfg -> cfg.setMaxEdgeEvents(MAX_EDGE_EVENTS)); |
|||
loginTenantAdmin(); |
|||
|
|||
for (int i = 0; i < MAX_EDGE_EVENTS + 5; i++) { |
|||
apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.EDGE_EVENT_COUNT); |
|||
} |
|||
|
|||
await().atMost(15, TimeUnit.SECONDS) |
|||
.until(() -> apiUsageStateService.findTenantApiUsageState(tenantId).getEdgeState() == ApiUsageStateValue.DISABLED); |
|||
await().atMost(10, TimeUnit.SECONDS) |
|||
.until(() -> !sessionConnected(tenantId)); |
|||
|
|||
loginSysAdmin(); |
|||
updateDefaultTenantProfileConfig(cfg -> cfg.setMaxEdgeEvents(0)); |
|||
loginTenantAdmin(); |
|||
|
|||
await().atMost(15, TimeUnit.SECONDS) |
|||
.until(() -> apiUsageStateService.findTenantApiUsageState(tenantId).getEdgeState() == ApiUsageStateValue.ENABLED); |
|||
|
|||
EdgeImitator reconnectImitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); |
|||
reconnectImitator.connect(); |
|||
|
|||
try { |
|||
await().atMost(15, TimeUnit.SECONDS) |
|||
.until(() -> sessionConnected(tenantId)); |
|||
Assert.assertNull("Imitator must not receive a close callback after re-enable", |
|||
reconnectImitator.getCloseException()); |
|||
} finally { |
|||
try { |
|||
reconnectImitator.disconnect(); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
} |
|||
|
|||
private boolean sessionConnected(TenantId tenantId) { |
|||
return edgeSessionsHolder.getByTenantId(tenantId).stream().anyMatch(s -> s.getState().isConnected()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,126 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.coap; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.awaitility.Awaitility; |
|||
import org.eclipse.californium.core.CoapObserveRelation; |
|||
import org.eclipse.californium.core.coap.CoAP; |
|||
import org.junit.After; |
|||
import org.junit.Assert; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.thingsboard.server.common.data.ApiUsageRecordKey; |
|||
import org.thingsboard.server.common.data.ApiUsageStateValue; |
|||
import org.thingsboard.server.common.data.CoapDeviceType; |
|||
import org.thingsboard.server.common.data.TenantProfile; |
|||
import org.thingsboard.server.common.data.TransportPayloadType; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
import org.thingsboard.server.common.msg.session.FeatureType; |
|||
import org.thingsboard.server.common.stats.TbApiUsageReportClient; |
|||
import org.thingsboard.server.common.transport.service.DefaultTransportService; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; |
|||
|
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
@DaoSqlTest |
|||
@TestPropertySource(properties = { |
|||
"usage.stats.report.enabled=true", |
|||
"usage.stats.report.interval=2", |
|||
"usage.stats.report.urgent_interval=1", |
|||
}) |
|||
@Slf4j |
|||
public class CoapTransportFeatureDisabledTest extends AbstractCoapIntegrationTest { |
|||
|
|||
private static final int MAX_TRANSPORT_MESSAGES = 10; |
|||
private static final double WARN_THRESHOLD = 0.5; |
|||
|
|||
@Autowired |
|||
private ApiUsageStateService apiUsageStateService; |
|||
|
|||
@Autowired |
|||
private TbApiUsageReportClient apiUsageReportClient; |
|||
|
|||
@Autowired |
|||
private DefaultTransportService defaultTransportService; |
|||
|
|||
@Before |
|||
public void beforeTest() throws Exception { |
|||
loginSysAdmin(); |
|||
TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); |
|||
DefaultTenantProfileConfiguration config = |
|||
(DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); |
|||
config.setMaxTransportMessages(MAX_TRANSPORT_MESSAGES); |
|||
config.setWarnThreshold(WARN_THRESHOLD); |
|||
doPost("/api/tenantProfile", tenantProfile); |
|||
|
|||
CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() |
|||
.deviceName("Coap transport disable test device") |
|||
.coapDeviceType(CoapDeviceType.DEFAULT) |
|||
.transportPayloadType(TransportPayloadType.JSON) |
|||
.build(); |
|||
processBeforeTest(configProperties); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
try { |
|||
processAfterTest(); |
|||
} finally { |
|||
try { |
|||
loginSysAdmin(); |
|||
TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); |
|||
DefaultTenantProfileConfiguration config = |
|||
(DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); |
|||
config.setMaxTransportMessages(0); |
|||
doPost("/api/tenantProfile", tenantProfile); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void testCoapObserveSessionClosedWhenTransportDisabled() { |
|||
client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); |
|||
CoapTestCallback callback = new CoapTestCallback(); |
|||
CoapObserveRelation observeRelation = client.getObserveRelation(callback); |
|||
|
|||
Awaitility.await("await initial observe response") |
|||
.atMost(10, TimeUnit.SECONDS) |
|||
.until(() -> CoAP.ResponseCode.CONTENT.equals(callback.getResponseCode()) |
|||
&& callback.getObserve() != null); |
|||
|
|||
Assert.assertFalse("CoAP transport must hold at least one registered session after observe", |
|||
defaultTransportService.sessions.isEmpty()); |
|||
|
|||
for (int i = 0; i < MAX_TRANSPORT_MESSAGES + 5; i++) { |
|||
apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.TRANSPORT_MSG_COUNT); |
|||
} |
|||
|
|||
Awaitility.await("transport state flips to DISABLED") |
|||
.atMost(15, TimeUnit.SECONDS) |
|||
.until(() -> apiUsageStateService.findTenantApiUsageState(tenantId).getTransportState() == ApiUsageStateValue.DISABLED); |
|||
|
|||
Awaitility.await("CoAP session is removed from DefaultTransportService") |
|||
.atMost(10, TimeUnit.SECONDS) |
|||
.until(() -> defaultTransportService.sessions.isEmpty()); |
|||
|
|||
observeRelation.proactiveCancel(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.mqtt; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.awaitility.Awaitility; |
|||
import org.junit.After; |
|||
import org.junit.Assert; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.DynamicPropertyRegistry; |
|||
import org.springframework.test.context.DynamicPropertySource; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.thingsboard.server.common.data.ApiUsageRecordKey; |
|||
import org.thingsboard.server.common.data.ApiUsageStateValue; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.TenantProfile; |
|||
import org.thingsboard.server.common.data.security.DeviceCredentials; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
import org.thingsboard.server.common.stats.TbApiUsageReportClient; |
|||
import org.thingsboard.server.controller.AbstractControllerTest; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; |
|||
import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; |
|||
|
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; |
|||
|
|||
@DaoSqlTest |
|||
@TestPropertySource(properties = { |
|||
"service.integrations.supported=ALL", |
|||
"transport.mqtt.enabled=true", |
|||
"usage.stats.report.enabled=true", |
|||
"usage.stats.report.interval=2", |
|||
"usage.stats.report.urgent_interval=1", |
|||
}) |
|||
@Slf4j |
|||
public class MqttTransportFeatureDisabledTest extends AbstractControllerTest { |
|||
|
|||
private static final int MAX_TRANSPORT_MESSAGES = 10; |
|||
private static final double WARN_THRESHOLD = 0.5; |
|||
|
|||
@DynamicPropertySource |
|||
static void props(DynamicPropertyRegistry registry) { |
|||
log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); |
|||
registry.add("transport.mqtt.bind_port", () -> MQTT_PORT); |
|||
} |
|||
|
|||
@Autowired |
|||
private ApiUsageStateService apiUsageStateService; |
|||
|
|||
@Autowired |
|||
private TbApiUsageReportClient apiUsageReportClient; |
|||
|
|||
private String deviceAccessToken; |
|||
|
|||
@Before |
|||
public void beforeTest() throws Exception { |
|||
loginSysAdmin(); |
|||
TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); |
|||
DefaultTenantProfileConfiguration config = |
|||
(DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); |
|||
config.setMaxTransportMessages(MAX_TRANSPORT_MESSAGES); |
|||
config.setWarnThreshold(WARN_THRESHOLD); |
|||
doPost("/api/tenantProfile", tenantProfile); |
|||
|
|||
loginTenantAdmin(); |
|||
Device device = new Device(); |
|||
device.setName("Transport disable test device"); |
|||
device.setType("default"); |
|||
device = doPost("/api/device", device, Device.class); |
|||
DeviceCredentials credentials = doGet("/api/device/" + device.getId().getId() + "/credentials", DeviceCredentials.class); |
|||
deviceAccessToken = credentials.getCredentialsId(); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
try { |
|||
loginSysAdmin(); |
|||
TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); |
|||
DefaultTenantProfileConfiguration config = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); |
|||
config.setMaxTransportMessages(0); |
|||
doPost("/api/tenantProfile", tenantProfile); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
|
|||
@Test |
|||
public void testLiveMqttSessionClosedWhenTransportDisabled() throws Exception { |
|||
MqttTestClient client = new MqttTestClient(); |
|||
client.connectAndWait(deviceAccessToken); |
|||
Assert.assertTrue("MQTT client must be connected before flipping transport state", client.isConnected()); |
|||
|
|||
for (int i = 0; i < MAX_TRANSPORT_MESSAGES + 5; i++) { |
|||
apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.TRANSPORT_MSG_COUNT); |
|||
} |
|||
|
|||
Awaitility.await("transport state flips to DISABLED") |
|||
.atMost(15, TimeUnit.SECONDS) |
|||
.until(() -> apiUsageStateService.findTenantApiUsageState(tenantId).getTransportState() == ApiUsageStateValue.DISABLED); |
|||
|
|||
Awaitility.await("MQTT client receives server-side disconnect") |
|||
.atMost(10, TimeUnit.SECONDS) |
|||
.until(() -> !client.isConnected()); |
|||
|
|||
try { |
|||
client.disconnectForcibly(); |
|||
} catch (Exception ignored) {} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
/** |
|||
* 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.edge.exception; |
|||
|
|||
import java.io.Serial; |
|||
|
|||
public class EdgeFeatureDisabledException extends RuntimeException { |
|||
|
|||
@Serial |
|||
private static final long serialVersionUID = -4719918663724404639L; |
|||
|
|||
public EdgeFeatureDisabledException(String message) { |
|||
super(message); |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue