47 changed files with 2586 additions and 197 deletions
@ -0,0 +1,235 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.coapserver; |
||||
|
|
||||
|
import org.eclipse.californium.core.CoapServer; |
||||
|
import org.eclipse.californium.core.network.CoapEndpoint; |
||||
|
import org.eclipse.californium.core.network.Endpoint; |
||||
|
import org.eclipse.californium.elements.config.Configuration; |
||||
|
import org.eclipse.californium.scandium.DTLSConnector; |
||||
|
import org.eclipse.californium.scandium.config.DtlsConnectorConfig; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.Mockito; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CopyOnWriteArrayList; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.doReturn; |
||||
|
import static org.mockito.Mockito.doThrow; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
public class CoapDtlsCertificateReloadTest { |
||||
|
|
||||
|
@Mock |
||||
|
private CoapServerContext mockCoapServerContext; |
||||
|
|
||||
|
@Mock |
||||
|
private TbCoapDtlsSettings mockDtlsSettings; |
||||
|
|
||||
|
@Mock |
||||
|
private CoapServer mockCoapServer; |
||||
|
|
||||
|
@Mock |
||||
|
private CoapEndpoint mockDtlsEndpoint; |
||||
|
|
||||
|
@Mock |
||||
|
private DTLSConnector mockDtlsConnector; |
||||
|
|
||||
|
private DefaultCoapServerService coapServerService; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() { |
||||
|
coapServerService = new DefaultCoapServerService(); |
||||
|
ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); |
||||
|
|
||||
|
when(mockCoapServerContext.getHost()).thenReturn("localhost"); |
||||
|
when(mockCoapServerContext.getPort()).thenReturn(5683); |
||||
|
doAnswer(invocation -> { |
||||
|
invocation.getArgument(0); |
||||
|
return null; |
||||
|
}).when(mockDtlsSettings).registerReloadCallback(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { |
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
|
||||
|
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
||||
|
assertThat(callbackCaptor.getValue()).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { |
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
||||
|
|
||||
|
verify(mockDtlsSettings, never()).registerReloadCallback(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { |
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
|
||||
|
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
||||
|
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
||||
|
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
||||
|
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
// dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw.
|
||||
|
// The old endpoint should not be stopped/destroyed when creation of the new one fails.
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
verify(mockDtlsEndpoint, never()).stop(); |
||||
|
verify(mockDtlsConnector, never()).destroy(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { |
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
when(mockCoapServerContext.getHost()).thenReturn("localhost"); |
||||
|
when(mockCoapServerContext.getPort()).thenReturn(5683); |
||||
|
|
||||
|
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
||||
|
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
||||
|
|
||||
|
verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { |
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
||||
|
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
||||
|
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); |
||||
|
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
assertThat(reloadCallback).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { |
||||
|
// GIVEN
|
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
|
||||
|
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
||||
|
DTLSConnector mockNewConnector = mock(DTLSConnector.class); |
||||
|
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
||||
|
TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); |
||||
|
when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); |
||||
|
|
||||
|
DefaultCoapServerService spyService = Mockito.spy(coapServerService); |
||||
|
ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); |
||||
|
ReflectionTestUtils.setField(spyService, "server", mockCoapServer); |
||||
|
ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
||||
|
ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); |
||||
|
|
||||
|
doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); |
||||
|
doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); |
||||
|
doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); |
||||
|
|
||||
|
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
||||
|
endpointsList.add(mockDtlsEndpoint); |
||||
|
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
||||
|
|
||||
|
// WHEN
|
||||
|
ReflectionTestUtils.invokeMethod(spyService, "recreateDtlsEndpoint"); |
||||
|
|
||||
|
// THEN
|
||||
|
assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); |
||||
|
verify(mockDtlsEndpoint).stop(); |
||||
|
verify(mockDtlsEndpoint).destroy(); |
||||
|
verify(mockDtlsConnector).destroy(); |
||||
|
verify(mockCoapServer).addEndpoint(mockNewEndpoint); |
||||
|
verify(mockNewEndpoint).start(); |
||||
|
assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenStartFails_thenNewResourcesCleaned() throws Exception { |
||||
|
// GIVEN
|
||||
|
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
||||
|
|
||||
|
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
||||
|
DTLSConnector mockNewConnector = mock(DTLSConnector.class); |
||||
|
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
||||
|
|
||||
|
doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); |
||||
|
|
||||
|
DefaultCoapServerService spyService = Mockito.spy(coapServerService); |
||||
|
ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); |
||||
|
ReflectionTestUtils.setField(spyService, "server", mockCoapServer); |
||||
|
ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
||||
|
ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); |
||||
|
|
||||
|
doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); |
||||
|
doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); |
||||
|
doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); |
||||
|
|
||||
|
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
||||
|
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
||||
|
|
||||
|
// WHEN - the callback catches the IOException internally
|
||||
|
spyService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
// THEN - new resources cleaned up
|
||||
|
verify(mockNewEndpoint).destroy(); |
||||
|
verify(mockNewConnector).destroy(); |
||||
|
assertThat(endpointsList).doesNotContain(mockNewEndpoint); |
||||
|
// Old fields preserved
|
||||
|
assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); |
||||
|
assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); |
||||
|
// Old endpoint not touched
|
||||
|
verify(mockDtlsEndpoint, never()).stop(); |
||||
|
verify(mockDtlsEndpoint, never()).destroy(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
class ResourceUtilsTest { |
||||
|
|
||||
|
@Test |
||||
|
public void givenNonExistentResource_whenGetUri_thenReturnsNull() { |
||||
|
String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"); |
||||
|
|
||||
|
assertThat(result).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { |
||||
|
String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); |
||||
|
|
||||
|
assertThat(result).isNotNull(); |
||||
|
assertThat(result).contains("ResourceUtilsTest"); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,192 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.transport.lwm2m.bootstrap; |
||||
|
|
||||
|
import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.Mockito; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.common.transport.TransportService; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
||||
|
import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; |
||||
|
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; |
||||
|
import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; |
||||
|
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; |
||||
|
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.doReturn; |
||||
|
import static org.mockito.Mockito.doThrow; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
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) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
public class LwM2mBootstrapCertificateReloadTest { |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2MTransportServerConfig mockServerConfig; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2MTransportBootstrapConfig mockBootstrapConfig; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2MBootstrapSecurityStore mockSecurityStore; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2MInMemoryBootstrapConfigStore mockConfigStore; |
||||
|
|
||||
|
@Mock |
||||
|
private TransportService mockTransportService; |
||||
|
|
||||
|
@Mock |
||||
|
private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; |
||||
|
|
||||
|
@Mock |
||||
|
private LeshanBootstrapServer mockBootstrapServer; |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentials mockSslCredentials; |
||||
|
|
||||
|
private LwM2MTransportBootstrapService bootstrapService; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() { |
||||
|
bootstrapService = new LwM2MTransportBootstrapService( |
||||
|
mockServerConfig, |
||||
|
mockBootstrapConfig, |
||||
|
mockSecurityStore, |
||||
|
mockConfigStore, |
||||
|
mockTransportService, |
||||
|
mockCertificateVerifier |
||||
|
); |
||||
|
|
||||
|
when(mockBootstrapConfig.getHost()).thenReturn("localhost"); |
||||
|
when(mockBootstrapConfig.getPort()).thenReturn(5687); |
||||
|
when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); |
||||
|
when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); |
||||
|
when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); |
||||
|
when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { |
||||
|
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
||||
|
|
||||
|
bootstrapService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
assertThat(callbackCaptor.getValue()).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { |
||||
|
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
||||
|
|
||||
|
// Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw)
|
||||
|
when(mockBootstrapConfig.getHost()).thenReturn(null); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
bootstrapService.afterSingletonsInstantiated(); |
||||
|
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
// getLhBootstrapServer() will fail due to null host.
|
||||
|
// With create-then-swap, the old server should NOT be destroyed.
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
verify(mockBootstrapServer, never()).destroy(); |
||||
|
assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenNullServer_whenRecreate_thenShouldNotThrow() { |
||||
|
ReflectionTestUtils.setField(bootstrapService, "server", null); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
bootstrapService.afterSingletonsInstantiated(); |
||||
|
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
// Should not throw — callback catches exceptions internally
|
||||
|
reloadCallback.run(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { |
||||
|
SslCredentials oldCredentials = mockSslCredentials; |
||||
|
SslCredentials newCredentials = mock(SslCredentials.class); |
||||
|
|
||||
|
when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); |
||||
|
|
||||
|
SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); |
||||
|
assertThat(firstCall).isEqualTo(oldCredentials); |
||||
|
|
||||
|
SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); |
||||
|
assertThat(secondCall).isEqualTo(newCredentials); |
||||
|
|
||||
|
verify(mockBootstrapConfig, times(2)).getSslCredentials(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { |
||||
|
bootstrapService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndOldPreserved() { |
||||
|
// GIVEN
|
||||
|
ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); |
||||
|
|
||||
|
LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); |
||||
|
doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); |
||||
|
|
||||
|
LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); |
||||
|
doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
spyService.afterSingletonsInstantiated(); |
||||
|
verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
// WHEN
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
// THEN
|
||||
|
verify(mockNewServer).destroy(); |
||||
|
assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); |
||||
|
verify(mockBootstrapServer, never()).destroy(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,188 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.transport.lwm2m.server; |
||||
|
|
||||
|
import org.eclipse.leshan.server.LeshanServer; |
||||
|
import org.eclipse.leshan.server.observation.ObservationService; |
||||
|
import org.eclipse.leshan.server.registration.RegistrationService; |
||||
|
import org.eclipse.leshan.server.registration.RegistrationStore; |
||||
|
import org.eclipse.leshan.server.send.SendService; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.cache.ota.OtaPackageDataCache; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
||||
|
import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; |
||||
|
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; |
||||
|
import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; |
||||
|
import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; |
||||
|
import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
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) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
public class LwM2mServerCertificateReloadTest { |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2mTransportContext mockContext; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2MTransportServerConfig mockConfig; |
||||
|
|
||||
|
@Mock |
||||
|
private OtaPackageDataCache mockOtaCache; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2mUplinkMsgHandler mockHandler; |
||||
|
|
||||
|
@Mock |
||||
|
private RegistrationStore mockRegistrationStore; |
||||
|
|
||||
|
@Mock |
||||
|
private TbSecurityStore mockSecurityStore; |
||||
|
|
||||
|
@Mock |
||||
|
private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; |
||||
|
|
||||
|
@Mock |
||||
|
private TbLwM2MAuthorizer mockAuthorizer; |
||||
|
|
||||
|
@Mock |
||||
|
private LwM2mVersionedModelProvider mockModelProvider; |
||||
|
|
||||
|
@Mock |
||||
|
private LeshanServer mockLeshanServer; |
||||
|
|
||||
|
@Mock |
||||
|
private RegistrationService mockRegistrationService; |
||||
|
|
||||
|
@Mock |
||||
|
private ObservationService mockObservationService; |
||||
|
|
||||
|
@Mock |
||||
|
private SendService mockSendService; |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentials mockSslCredentials; |
||||
|
|
||||
|
private DefaultLwM2mTransportService lwm2mTransportService; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() { |
||||
|
lwm2mTransportService = new DefaultLwM2mTransportService( |
||||
|
mockContext, |
||||
|
mockConfig, |
||||
|
mockOtaCache, |
||||
|
mockHandler, |
||||
|
mockRegistrationStore, |
||||
|
mockSecurityStore, |
||||
|
mockCertificateVerifier, |
||||
|
mockAuthorizer, |
||||
|
mockModelProvider |
||||
|
); |
||||
|
|
||||
|
when(mockConfig.getHost()).thenReturn("localhost"); |
||||
|
when(mockConfig.getPort()).thenReturn(5683); |
||||
|
when(mockConfig.getSecureHost()).thenReturn("localhost"); |
||||
|
when(mockConfig.getSecurePort()).thenReturn(5684); |
||||
|
when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); |
||||
|
|
||||
|
when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); |
||||
|
when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); |
||||
|
when(mockLeshanServer.getSendService()).thenReturn(mockSendService); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { |
||||
|
lwm2mTransportService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
assertThat(callbackCaptor.getValue()).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { |
||||
|
lwm2mTransportService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); |
||||
|
|
||||
|
// Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw)
|
||||
|
when(mockConfig.getHost()).thenReturn(null); |
||||
|
|
||||
|
// With create-then-swap, the old server should NOT be destroyed if the new one fails.
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
verify(mockLeshanServer, never()).destroy(); |
||||
|
// Old server should still be the active one
|
||||
|
assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { |
||||
|
lwm2mTransportService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); |
||||
|
|
||||
|
LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); |
||||
|
ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); |
||||
|
|
||||
|
// Invoke the callback — new server creation will fail, old listeners should stay
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
verify(mockRegistrationService, never()).removeListener(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { |
||||
|
lwm2mTransportService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
verify(mockConfig, times(1)).registerServerReloadCallback(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { |
||||
|
lwm2mTransportService.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
ReflectionTestUtils.setField(lwm2mTransportService, "server", null); |
||||
|
|
||||
|
// Should not throw - callback catches exceptions internally
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
/** |
||||
|
* 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 io.netty.handler.ssl.SslHandler; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.server.common.transport.TransportService; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
||||
|
|
||||
|
import javax.net.ssl.KeyManager; |
||||
|
import javax.net.ssl.KeyManagerFactory; |
||||
|
import javax.net.ssl.SSLContext; |
||||
|
import javax.net.ssl.TrustManager; |
||||
|
import javax.net.ssl.TrustManagerFactory; |
||||
|
import javax.net.ssl.X509TrustManager; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class MqttSslHandlerProviderTest { |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentialsConfig mockCredentialsConfig; |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentials mockCredentials; |
||||
|
|
||||
|
@Mock |
||||
|
private TransportService mockTransportService; |
||||
|
|
||||
|
private MqttSslHandlerProvider sslHandlerProvider; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() throws Exception { |
||||
|
sslHandlerProvider = new MqttSslHandlerProvider(); |
||||
|
ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); |
||||
|
ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); |
||||
|
ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); |
||||
|
|
||||
|
KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); |
||||
|
TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); |
||||
|
X509TrustManager mockTrustManager = mock(X509TrustManager.class); |
||||
|
|
||||
|
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); |
||||
|
when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); |
||||
|
when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); |
||||
|
when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); |
||||
|
when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { |
||||
|
sslHandlerProvider.afterSingletonsInstantiated(); |
||||
|
|
||||
|
SslHandler handler1 = sslHandlerProvider.getSslHandler(); |
||||
|
SslHandler handler2 = sslHandlerProvider.getSslHandler(); |
||||
|
|
||||
|
assertThat(handler1).isNotNull(); |
||||
|
assertThat(handler2).isNotNull(); |
||||
|
assertThat(handler1).isNotSameAs(handler2); |
||||
|
|
||||
|
SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(context).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificatesReloaded_whenGetSslHandler_thenShouldRecreateSSLContext() { |
||||
|
sslHandlerProvider.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
SslHandler handler1 = sslHandlerProvider.getSslHandler(); |
||||
|
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(initialContext).isNotNull(); |
||||
|
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
assertThat(handler1).isNotNull(); |
||||
|
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(contextAfterReload).isNull(); |
||||
|
|
||||
|
SslHandler handler2 = sslHandlerProvider.getSslHandler(); |
||||
|
SSLContext newContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
|
||||
|
assertThat(handler2).isNotNull(); |
||||
|
assertThat(newContext).isNotNull(); |
||||
|
assertThat(newContext).isNotSameAs(initialContext); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenConcurrentGetSslHandlerCalls_whenSSLContextNull_thenShouldCreateOnlyOnce() throws Exception { |
||||
|
sslHandlerProvider.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
CountDownLatch startLatch = new CountDownLatch(1); |
||||
|
CountDownLatch doneLatch = new CountDownLatch(5); |
||||
|
|
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
new Thread(() -> { |
||||
|
try { |
||||
|
startLatch.await(); |
||||
|
SslHandler handler = sslHandlerProvider.getSslHandler(); |
||||
|
assertThat(handler).isNotNull(); |
||||
|
} catch (Exception e) { |
||||
|
throw new RuntimeException(e); |
||||
|
} finally { |
||||
|
doneLatch.countDown(); |
||||
|
} |
||||
|
}).start(); |
||||
|
} |
||||
|
|
||||
|
startLatch.countDown(); |
||||
|
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(completed).isTrue(); |
||||
|
SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(context).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenInvoked_thenShouldInvalidateSSLContext() { |
||||
|
sslHandlerProvider.afterSingletonsInstantiated(); |
||||
|
|
||||
|
sslHandlerProvider.getSslHandler(); |
||||
|
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(initialContext).isNotNull(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(contextAfterReload).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { |
||||
|
sslHandlerProvider.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
SSLContext context1; |
||||
|
SSLContext context2; |
||||
|
SSLContext context3; |
||||
|
|
||||
|
sslHandlerProvider.getSslHandler(); |
||||
|
context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(context1).isNotNull(); |
||||
|
|
||||
|
reloadCallback.run(); |
||||
|
sslHandlerProvider.getSslHandler(); |
||||
|
context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(context2).isNotNull(); |
||||
|
assertThat(context2).isNotSameAs(context1); |
||||
|
|
||||
|
reloadCallback.run(); |
||||
|
sslHandlerProvider.getSslHandler(); |
||||
|
context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
||||
|
assertThat(context3).isNotNull(); |
||||
|
assertThat(context3).isNotSameAs(context2); |
||||
|
assertThat(context3).isNotSameAs(context1); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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.common.transport.service; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.DisposableBean; |
||||
|
import org.springframework.beans.factory.SmartInitializingSingleton; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.beans.factory.annotation.Value; |
||||
|
import org.springframework.context.ApplicationContext; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
||||
|
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
||||
|
import org.thingsboard.server.queue.util.TbTransportComponent; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.security.MessageDigest; |
||||
|
import java.util.Base64; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.ScheduledExecutorService; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
@TbTransportComponent |
||||
|
public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { |
||||
|
|
||||
|
private static final int MAX_CONSECUTIVE_FAILURES = 10; |
||||
|
|
||||
|
@Value("${transport.ssl.certificate.reload.enabled:true}") |
||||
|
private boolean reloadEnabled; |
||||
|
|
||||
|
@Value("${transport.ssl.certificate.reload.check_interval:60}") |
||||
|
private long checkIntervalInSeconds; |
||||
|
|
||||
|
@Autowired |
||||
|
protected ApplicationContext applicationContext; |
||||
|
|
||||
|
private final Map<String, CertificateWatcher> watchers = new ConcurrentHashMap<>(); |
||||
|
private volatile ScheduledExecutorService scheduler; |
||||
|
|
||||
|
public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { |
||||
|
registerWatcher(name, List.of(certPath), reloadCallback); |
||||
|
} |
||||
|
|
||||
|
public void registerWatcher(String name, List<Path> certPaths, Runnable reloadCallback) { |
||||
|
watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); |
||||
|
log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); |
||||
|
} |
||||
|
|
||||
|
private void checkCertificates() { |
||||
|
watchers.forEach((name, watcher) -> { |
||||
|
try { |
||||
|
watcher.checkAndReload(name); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private void discoverAndRegisterSslCredentials() { |
||||
|
try { |
||||
|
Map<String, SslCredentialsConfig> sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); |
||||
|
|
||||
|
log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); |
||||
|
|
||||
|
for (Map.Entry<String, SslCredentialsConfig> entry : sslConfigBeans.entrySet()) { |
||||
|
String beanName = entry.getKey(); |
||||
|
SslCredentialsConfig config = entry.getValue(); |
||||
|
|
||||
|
try { |
||||
|
if (!config.isEnabled()) { |
||||
|
log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
SslCredentials credentials = config.getCredentials(); |
||||
|
if (credentials == null) { |
||||
|
log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
List<Path> filePaths = credentials.getCertificateFilePaths(); |
||||
|
if (filePaths == null || filePaths.isEmpty()) { |
||||
|
log.debug("No certificate files to watch for: {} ({})", config.getName(), beanName); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
List<Path> existingPaths = filePaths.stream() |
||||
|
.filter(p -> p != null && Files.exists(p)) |
||||
|
.toList(); |
||||
|
|
||||
|
for (Path filePath : filePaths) { |
||||
|
if (filePath == null || !Files.exists(filePath)) { |
||||
|
log.warn("Certificate file does not exist: {} (from {})", filePath, config.getName()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!existingPaths.isEmpty()) { |
||||
|
registerWatcher(config.getName(), existingPaths, config::onCertificateFileChanged); |
||||
|
log.info("Registered certificate watcher: {} -> {}", config.getName(), existingPaths); |
||||
|
} |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error("Error discovering SSL credentials configs", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void destroy() throws Exception { |
||||
|
if (scheduler != null) { |
||||
|
scheduler.shutdown(); |
||||
|
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { |
||||
|
scheduler.shutdownNow(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void afterSingletonsInstantiated() { |
||||
|
if (!reloadEnabled) { |
||||
|
log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); |
||||
|
return; |
||||
|
} |
||||
|
log.info("Initializing Certificate Reload Manager..."); |
||||
|
|
||||
|
discoverAndRegisterSslCredentials(); |
||||
|
|
||||
|
scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); |
||||
|
scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); |
||||
|
} |
||||
|
|
||||
|
static class CertificateWatcher { |
||||
|
private final List<Path> paths; |
||||
|
private final Runnable reloadCallback; |
||||
|
private final Map<Path, Long> lastModifiedMap; |
||||
|
private final Map<Path, String> lastChecksumMap; |
||||
|
private int consecutiveFailures; |
||||
|
private String failedCombinedChecksum; |
||||
|
|
||||
|
CertificateWatcher(List<Path> paths, Runnable reloadCallback) { |
||||
|
this.paths = paths; |
||||
|
this.reloadCallback = reloadCallback; |
||||
|
this.lastModifiedMap = new HashMap<>(); |
||||
|
this.lastChecksumMap = new HashMap<>(); |
||||
|
for (Path path : paths) { |
||||
|
lastModifiedMap.put(path, getLastModifiedTime(path)); |
||||
|
lastChecksumMap.put(path, calculateChecksum(path)); |
||||
|
} |
||||
|
this.consecutiveFailures = 0; |
||||
|
} |
||||
|
|
||||
|
synchronized void checkAndReload(String name) { |
||||
|
boolean anyModifiedChanged = false; |
||||
|
for (Path path : paths) { |
||||
|
long currentModified = getLastModifiedTime(path); |
||||
|
Long lastModified = lastModifiedMap.getOrDefault(path, 0L); |
||||
|
if (currentModified != lastModified) { |
||||
|
anyModifiedChanged = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
if (!anyModifiedChanged) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Compute combined checksum of all files
|
||||
|
Map<Path, String> currentChecksums = new HashMap<>(); |
||||
|
StringBuilder combined = new StringBuilder(); |
||||
|
for (Path path : paths) { |
||||
|
String checksum = calculateChecksum(path); |
||||
|
currentChecksums.put(path, checksum); |
||||
|
combined.append(checksum); |
||||
|
} |
||||
|
String combinedChecksum = combined.toString(); |
||||
|
|
||||
|
// Build old combined checksum for comparison
|
||||
|
StringBuilder oldCombined = new StringBuilder(); |
||||
|
for (Path path : paths) { |
||||
|
oldCombined.append(lastChecksumMap.getOrDefault(path, "")); |
||||
|
} |
||||
|
String oldCombinedChecksum = oldCombined.toString(); |
||||
|
|
||||
|
if (combinedChecksum.equals(oldCombinedChecksum)) { |
||||
|
// Content unchanged, just update modification times
|
||||
|
for (Path path : paths) { |
||||
|
lastModifiedMap.put(path, getLastModifiedTime(path)); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { |
||||
|
// File content changed since last failure — reset and retry
|
||||
|
consecutiveFailures = 0; |
||||
|
failedCombinedChecksum = null; |
||||
|
} |
||||
|
|
||||
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
log.info("Certificate change detected for: {}. Triggering reload...", name); |
||||
|
reloadCallback.run(); |
||||
|
for (Path path : paths) { |
||||
|
lastModifiedMap.put(path, getLastModifiedTime(path)); |
||||
|
lastChecksumMap.put(path, currentChecksums.get(path)); |
||||
|
} |
||||
|
consecutiveFailures = 0; |
||||
|
failedCombinedChecksum = null; |
||||
|
} catch (Exception e) { |
||||
|
consecutiveFailures++; |
||||
|
failedCombinedChecksum = combinedChecksum; |
||||
|
log.error("Failed to reload certificate for {} (attempt {}/{}): {}", |
||||
|
name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private long getLastModifiedTime(Path path) { |
||||
|
try { |
||||
|
if (!Files.exists(path)) { |
||||
|
return 0; |
||||
|
} |
||||
|
return Files.getLastModifiedTime(path).toMillis(); |
||||
|
} catch (IOException e) { |
||||
|
return 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String calculateChecksum(Path path) { |
||||
|
try { |
||||
|
if (!Files.exists(path)) { |
||||
|
return ""; |
||||
|
} |
||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
|
byte[] bytes = Files.readAllBytes(path); |
||||
|
byte[] hash = md.digest(bytes); |
||||
|
return Base64.getEncoder().encodeToString(hash); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("Failed to calculate checksum for certificate file: {}", path, e); |
||||
|
return ""; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,182 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.transport.config.ssl; |
||||
|
|
||||
|
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 java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.Mockito.doNothing; |
||||
|
import static org.mockito.Mockito.doThrow; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class SslCredentialsConfigTest { |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentials mockCredentials; |
||||
|
|
||||
|
private SslCredentialsConfig config; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() { |
||||
|
config = new SslCredentialsConfig("Test SSL Config", false); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenConfig_whenCreated_thenShouldHaveCorrectName() { |
||||
|
assertThat(config.getName()).isEqualTo("Test SSL Config"); |
||||
|
assertThat(config.isTrustsOnly()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { |
||||
|
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); |
||||
|
assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallback_whenRegistered_thenShouldBeStoredInList() { |
||||
|
AtomicInteger callCount = new AtomicInteger(0); |
||||
|
|
||||
|
config.registerReloadCallback(callCount::incrementAndGet); |
||||
|
config.setCredentials(mockCredentials); |
||||
|
|
||||
|
try { |
||||
|
doNothing().when(mockCredentials).reload(false); |
||||
|
} catch (Exception e) { |
||||
|
throw new RuntimeException(e); |
||||
|
} |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
assertThat(callCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { |
||||
|
AtomicInteger callback1Count = new AtomicInteger(0); |
||||
|
AtomicInteger callback2Count = new AtomicInteger(0); |
||||
|
AtomicInteger callback3Count = new AtomicInteger(0); |
||||
|
|
||||
|
config.registerReloadCallback(callback1Count::incrementAndGet); |
||||
|
config.registerReloadCallback(callback2Count::incrementAndGet); |
||||
|
config.registerReloadCallback(callback3Count::incrementAndGet); |
||||
|
|
||||
|
config.setCredentials(mockCredentials); |
||||
|
doNothing().when(mockCredentials).reload(false); |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
assertThat(callback1Count.get()).isEqualTo(1); |
||||
|
assertThat(callback2Count.get()).isEqualTo(1); |
||||
|
assertThat(callback3Count.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { |
||||
|
AtomicInteger callback1Count = new AtomicInteger(0); |
||||
|
AtomicInteger callback2Count = new AtomicInteger(0); |
||||
|
|
||||
|
config.registerReloadCallback(() -> { |
||||
|
callback1Count.incrementAndGet(); |
||||
|
throw new RuntimeException("Simulated callback failure"); |
||||
|
}); |
||||
|
config.registerReloadCallback(callback2Count::incrementAndGet); |
||||
|
|
||||
|
config.setCredentials(mockCredentials); |
||||
|
doNothing().when(mockCredentials).reload(false); |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
assertThat(callback1Count.get()).isEqualTo(1); |
||||
|
assertThat(callback2Count.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCredentialsReloadFails_whenCertificateChanged_thenCallbacksShouldNotBeCalled() throws Exception { |
||||
|
AtomicInteger callbackCount = new AtomicInteger(0); |
||||
|
|
||||
|
config.registerReloadCallback(callbackCount::incrementAndGet); |
||||
|
config.setCredentials(mockCredentials); |
||||
|
|
||||
|
doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
assertThat(callbackCount.get()).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { |
||||
|
config.setCredentials(mockCredentials); |
||||
|
doNothing().when(mockCredentials).reload(false); |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
verify(mockCredentials).reload(false); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { |
||||
|
SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); |
||||
|
trustsOnlyConfig.setCredentials(mockCredentials); |
||||
|
doNothing().when(mockCredentials).reload(true); |
||||
|
|
||||
|
trustsOnlyConfig.onCertificateFileChanged(); |
||||
|
|
||||
|
verify(mockCredentials).reload(true); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { |
||||
|
AtomicInteger totalCallbacks = new AtomicInteger(0); |
||||
|
CountDownLatch startLatch = new CountDownLatch(1); |
||||
|
CountDownLatch doneLatch = new CountDownLatch(10); |
||||
|
|
||||
|
for (int i = 0; i < 10; i++) { |
||||
|
new Thread(() -> { |
||||
|
try { |
||||
|
startLatch.await(); |
||||
|
config.registerReloadCallback(totalCallbacks::incrementAndGet); |
||||
|
} catch (Exception e) { |
||||
|
throw new RuntimeException(e); |
||||
|
} finally { |
||||
|
doneLatch.countDown(); |
||||
|
} |
||||
|
}).start(); |
||||
|
} |
||||
|
|
||||
|
startLatch.countDown(); |
||||
|
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
||||
|
assertThat(completed).isTrue(); |
||||
|
|
||||
|
config.setCredentials(mockCredentials); |
||||
|
doNothing().when(mockCredentials).reload(false); |
||||
|
|
||||
|
config.onCertificateFileChanged(); |
||||
|
|
||||
|
assertThat(totalCallbacks.get()).isEqualTo(10); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,277 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.transport.config.ssl; |
||||
|
|
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.boot.autoconfigure.web.ServerProperties; |
||||
|
import org.springframework.boot.ssl.SslBundle; |
||||
|
import org.springframework.boot.ssl.SslBundles; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
|
||||
|
import java.security.KeyStore; |
||||
|
import java.security.cert.X509Certificate; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
import java.util.function.Consumer; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
public class SslCredentialsWebServerCustomizerTest { |
||||
|
|
||||
|
@Mock |
||||
|
private ServerProperties mockServerProperties; |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentialsConfig mockCredentialsConfig; |
||||
|
|
||||
|
@Mock |
||||
|
private SslCredentials mockCredentials; |
||||
|
|
||||
|
@Mock |
||||
|
private KeyStore mockKeyStore; |
||||
|
|
||||
|
private SslCredentialsWebServerCustomizer customizer; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() throws Exception { |
||||
|
customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); |
||||
|
ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); |
||||
|
|
||||
|
when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); |
||||
|
when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); |
||||
|
when(mockCredentials.getKeyPassword()).thenReturn("password"); |
||||
|
when(mockCredentials.getKeyAlias()).thenReturn("server"); |
||||
|
|
||||
|
X509Certificate mockCert = mock(X509Certificate.class); |
||||
|
when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); |
||||
|
when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { |
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
assertThat(callbackCaptor.getValue()).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { |
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
|
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
verify(mockCredentialsConfig, times(1)).getCredentials(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
|
||||
|
SslBundle bundle = sslBundles.getBundle("default"); |
||||
|
|
||||
|
assertThat(bundle).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
|
||||
|
List<String> bundleNames = sslBundles.getBundleNames(); |
||||
|
|
||||
|
assertThat(bundleNames).containsExactly("default"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handlerCallCount = new AtomicInteger(0); |
||||
|
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet(); |
||||
|
|
||||
|
sslBundles.addBundleUpdateHandler("default", handler); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
assertThat(handlerCallCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handlerCallCount = new AtomicInteger(0); |
||||
|
Consumer<SslBundle> handler = bundle -> handlerCallCount.incrementAndGet(); |
||||
|
|
||||
|
sslBundles.addBundleUpdateHandler("wrong-bundle", handler); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
assertThat(handlerCallCount.get()).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handler1CallCount = new AtomicInteger(0); |
||||
|
AtomicInteger handler2CallCount = new AtomicInteger(0); |
||||
|
AtomicInteger handler3CallCount = new AtomicInteger(0); |
||||
|
|
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); |
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); |
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
assertThat(handler1CallCount.get()).isEqualTo(1); |
||||
|
assertThat(handler2CallCount.get()).isEqualTo(1); |
||||
|
assertThat(handler3CallCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handlerCallCount = new AtomicInteger(0); |
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
reloadCallback.run(); |
||||
|
reloadCallback.run(); |
||||
|
reloadCallback.run(); |
||||
|
|
||||
|
assertThat(handlerCallCount.get()).isEqualTo(3); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handler1CallCount = new AtomicInteger(0); |
||||
|
AtomicInteger handler2CallCount = new AtomicInteger(0); |
||||
|
|
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> { |
||||
|
handler1CallCount.incrementAndGet(); |
||||
|
throw new RuntimeException("Handler 1 failed"); |
||||
|
}); |
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
|
||||
|
assertThat(handler1CallCount.get()).isEqualTo(1); |
||||
|
assertThat(handler2CallCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
AtomicInteger handlerCallCount = new AtomicInteger(0); |
||||
|
CountDownLatch startLatch = new CountDownLatch(1); |
||||
|
CountDownLatch doneLatch = new CountDownLatch(5); |
||||
|
|
||||
|
sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
Runnable reloadCallback = callbackCaptor.getValue(); |
||||
|
|
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
new Thread(() -> { |
||||
|
try { |
||||
|
startLatch.await(); |
||||
|
reloadCallback.run(); |
||||
|
} catch (Exception e) { |
||||
|
throw new RuntimeException(e); |
||||
|
} finally { |
||||
|
doneLatch.countDown(); |
||||
|
} |
||||
|
}).start(); |
||||
|
} |
||||
|
|
||||
|
startLatch.countDown(); |
||||
|
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(completed).isTrue(); |
||||
|
assertThat(handlerCallCount.get()).isEqualTo(5); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { |
||||
|
when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); |
||||
|
|
||||
|
customizer.afterSingletonsInstantiated(); |
||||
|
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
||||
|
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
||||
|
|
||||
|
callbackCaptor.getValue().run(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { |
||||
|
SslBundles sslBundles = customizer.sslBundles(); |
||||
|
|
||||
|
SslBundle bundle1 = sslBundles.getBundle("default"); |
||||
|
SslBundle bundle2 = sslBundles.getBundle("default"); |
||||
|
|
||||
|
assertThat(bundle1).isNotNull(); |
||||
|
assertThat(bundle2).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { |
||||
|
SslCredentialsConfig config = customizer.httpServerSslCredentials(); |
||||
|
|
||||
|
assertThat(config).isNotNull(); |
||||
|
assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); |
||||
|
assertThat(config.isTrustsOnly()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,383 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2026 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.transport.service; |
||||
|
|
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.io.TempDir; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
public class CertificateReloadManagerTest { |
||||
|
|
||||
|
@TempDir |
||||
|
Path tempDir; |
||||
|
|
||||
|
private CertificateReloadManager certificateReloadManager; |
||||
|
private Path certFile; |
||||
|
|
||||
|
@BeforeEach |
||||
|
public void setup() throws IOException { |
||||
|
certificateReloadManager = new CertificateReloadManager(); |
||||
|
|
||||
|
certFile = tempDir.resolve("test-cert.pem"); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
public void teardown() throws Exception { |
||||
|
if (certificateReloadManager != null) { |
||||
|
certificateReloadManager.destroy(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { |
||||
|
CountDownLatch reloadLatch = new CountDownLatch(1); |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
||||
|
reloadCount.incrementAndGet(); |
||||
|
reloadLatch.countDown(); |
||||
|
}); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(reloadTriggered).isTrue(); |
||||
|
assertThat(reloadCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
Thread.sleep(100); |
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
assertThat(reloadCount.get()).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
assertThat(reloadCount.get()).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
Files.delete(certFile); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// File deletion changes checksum from real hash to "", so reload is triggered
|
||||
|
assertThat(reloadCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { |
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); |
||||
|
|
||||
|
certificateReloadManager.destroy(); |
||||
|
|
||||
|
assertThat(certificateReloadManager).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { |
||||
|
Path keyFile = tempDir.resolve("test-key.pem"); |
||||
|
Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); |
||||
|
|
||||
|
CountDownLatch certReloadLatch = new CountDownLatch(1); |
||||
|
CountDownLatch keyReloadLatch = new CountDownLatch(1); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); |
||||
|
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(keyReloaded).isTrue(); |
||||
|
assertThat(certReloadLatch.getCount()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { |
||||
|
Path cert2File = tempDir.resolve("test-cert2.pem"); |
||||
|
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); |
||||
|
|
||||
|
AtomicInteger reload1Count = new AtomicInteger(0); |
||||
|
AtomicInteger reload2Count = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); |
||||
|
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); |
||||
|
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
Thread.sleep(200); |
||||
|
assertThat(reload1Count.get()).isEqualTo(1); |
||||
|
assertThat(reload2Count.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { |
||||
|
Path cert2File = tempDir.resolve("test-cert2.pem"); |
||||
|
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); |
||||
|
|
||||
|
AtomicInteger reload2Count = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { |
||||
|
throw new RuntimeException("Simulated reload failure"); |
||||
|
}); |
||||
|
certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); |
||||
|
Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
Thread.sleep(200); |
||||
|
assertThat(reload2Count.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
Files.delete(certFile); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
Thread.sleep(200); |
||||
|
assertThat(reloadCount.get()).isEqualTo(2); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { |
||||
|
CountDownLatch reloadLatch = new CountDownLatch(1); |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
||||
|
reloadCount.incrementAndGet(); |
||||
|
reloadLatch.countDown(); |
||||
|
}); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); |
||||
|
} |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(reloadTriggered).isTrue(); |
||||
|
assertThat(reloadCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
CountDownLatch startLatch = new CountDownLatch(1); |
||||
|
CountDownLatch doneLatch = new CountDownLatch(5); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
for (int i = 0; i < 5; i++) { |
||||
|
new Thread(() -> { |
||||
|
try { |
||||
|
startLatch.await(); |
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
} catch (Exception e) { |
||||
|
throw new RuntimeException(e); |
||||
|
} finally { |
||||
|
doneLatch.countDown(); |
||||
|
} |
||||
|
}).start(); |
||||
|
} |
||||
|
|
||||
|
startLatch.countDown(); |
||||
|
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
||||
|
|
||||
|
assertThat(completed).isTrue(); |
||||
|
// With atomic checkAndReload, exactly one reload should happen
|
||||
|
assertThat(reloadCount.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { |
||||
|
AtomicInteger reloadCount = new AtomicInteger(0); |
||||
|
String originalContent = Files.readString(certFile); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
Files.writeString(certFile, originalContent); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
|
||||
|
Thread.sleep(200); |
||||
|
assertThat(reloadCount.get()).isEqualTo(0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { |
||||
|
AtomicInteger reloadAttempts = new AtomicInteger(0); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
||||
|
reloadAttempts.incrementAndGet(); |
||||
|
throw new RuntimeException("Persistent failure"); |
||||
|
}); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops
|
||||
|
for (int i = 0; i < 15; i++) { |
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
} |
||||
|
|
||||
|
assertThat(reloadAttempts.get()).isEqualTo(10); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { |
||||
|
AtomicInteger reloadAttempts = new AtomicInteger(0); |
||||
|
AtomicInteger shouldFail = new AtomicInteger(1); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
||||
|
reloadAttempts.incrementAndGet(); |
||||
|
if (shouldFail.get() == 1) { |
||||
|
throw new RuntimeException("Transient failure"); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// First attempt fails
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
assertThat(reloadAttempts.get()).isEqualTo(1); |
||||
|
|
||||
|
// Fix the callback and change the file to new content
|
||||
|
shouldFail.set(0); |
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// Should reset failure counter and succeed because file content changed
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
assertThat(reloadAttempts.get()).isEqualTo(2); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { |
||||
|
AtomicInteger reloadAttempts = new AtomicInteger(0); |
||||
|
AtomicInteger shouldFail = new AtomicInteger(1); |
||||
|
|
||||
|
certificateReloadManager.registerWatcher("test-cert", certFile, () -> { |
||||
|
reloadAttempts.incrementAndGet(); |
||||
|
if (shouldFail.get() == 1) { |
||||
|
throw new RuntimeException("Persistent failure"); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// Exhaust all retries
|
||||
|
for (int i = 0; i < 15; i++) { |
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
} |
||||
|
assertThat(reloadAttempts.get()).isEqualTo(10); |
||||
|
|
||||
|
// Fix callback and change file to new content
|
||||
|
shouldFail.set(0); |
||||
|
Thread.sleep(100); |
||||
|
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); |
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
// Should detect new content, reset counter, and succeed
|
||||
|
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
||||
|
assertThat(reloadAttempts.get()).isEqualTo(11); |
||||
|
} |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue