Browse Source
Added automatic SSL/TLS certificate reload for transports without service restartpull/15538/head
committed by
GitHub
50 changed files with 3448 additions and 198 deletions
@ -0,0 +1,349 @@ |
|||
/** |
|||
* 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.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.bouncycastle.util.io.pem.PemObject; |
|||
import org.bouncycastle.util.io.pem.PemWriter; |
|||
import org.eclipse.californium.core.CoapClient; |
|||
import org.eclipse.californium.core.CoapResource; |
|||
import org.eclipse.californium.core.CoapResponse; |
|||
import org.eclipse.californium.core.CoapServer; |
|||
import org.eclipse.californium.core.coap.CoAP; |
|||
import org.eclipse.californium.core.config.CoapConfig; |
|||
import org.eclipse.californium.core.network.CoapEndpoint; |
|||
import org.eclipse.californium.core.server.resources.CoapExchange; |
|||
import org.eclipse.californium.elements.config.Configuration; |
|||
import org.eclipse.californium.elements.util.SslContextUtil; |
|||
import org.eclipse.californium.scandium.DTLSConnector; |
|||
import org.eclipse.californium.scandium.config.DtlsConfig; |
|||
import org.eclipse.californium.scandium.config.DtlsConnectorConfig; |
|||
import org.eclipse.californium.scandium.dtls.CertificateType; |
|||
import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; |
|||
import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.io.TempDir; |
|||
import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; |
|||
|
|||
import java.io.OutputStreamWriter; |
|||
import java.math.BigInteger; |
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.Collections; |
|||
import java.util.Date; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static java.util.concurrent.TimeUnit.MILLISECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; |
|||
import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; |
|||
|
|||
public class CoapDtlsCertificateReloadIntegrationTest { |
|||
|
|||
private static final String TEST_RESOURCE_PATH = "test"; |
|||
private static final String TEST_PAYLOAD = "hello-dtls"; |
|||
|
|||
@TempDir |
|||
Path tempDir; |
|||
|
|||
private CoapServer coapServer; |
|||
|
|||
@AfterEach |
|||
public void teardown() { |
|||
if (coapServer != null) { |
|||
coapServer.destroy(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointA); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse responseA = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(responseA).isNotNull(); |
|||
assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpointA); |
|||
endpointA.stop(); |
|||
|
|||
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointB); |
|||
endpointB.start(); |
|||
endpointA.destroy(); |
|||
|
|||
CoapResponse responseB = doDtlsRequest(dtlsPort, certB); |
|||
assertThat(responseB).isNotNull(); |
|||
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointA); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse responseA = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(responseA).isNotNull(); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpointA); |
|||
endpointA.stop(); |
|||
CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpointB); |
|||
endpointB.start(); |
|||
endpointA.destroy(); |
|||
|
|||
CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); |
|||
assertThat(failedResponse).isNull(); |
|||
|
|||
CoapResponse responseB = doDtlsRequest(dtlsPort, certB); |
|||
assertThat(responseB).isNotNull(); |
|||
assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
} |
|||
|
|||
@Test |
|||
public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { |
|||
KeyPair keyPair = generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, cert); |
|||
writeKeyPem(keyFile, keyPair); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
|
|||
Configuration config = createServerConfig(); |
|||
coapServer = new CoapServer(config); |
|||
coapServer.add(new TestResource()); |
|||
|
|||
int dtlsPort = findAvailablePort(); |
|||
CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpoint1); |
|||
coapServer.start(); |
|||
|
|||
CoapResponse response1 = doDtlsRequest(dtlsPort, cert); |
|||
assertThat(response1).isNotNull(); |
|||
assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
coapServer.getEndpoints().remove(endpoint1); |
|||
endpoint1.stop(); |
|||
CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); |
|||
coapServer.addEndpoint(endpoint2); |
|||
endpoint2.start(); |
|||
endpoint1.destroy(); |
|||
|
|||
CoapResponse response2 = doDtlsRequest(dtlsPort, cert); |
|||
assertThat(response2).isNotNull(); |
|||
assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); |
|||
} |
|||
|
|||
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { |
|||
PemSslCredentials pem = new PemSslCredentials(); |
|||
pem.setCertFile(certFile.toAbsolutePath().toString()); |
|||
pem.setKeyFile(keyFile.toAbsolutePath().toString()); |
|||
|
|||
SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); |
|||
config.setEnabled(true); |
|||
config.setType(SslCredentialsType.PEM); |
|||
config.setPem(pem); |
|||
config.setKeystore(new KeystoreSslCredentials()); |
|||
config.init(); |
|||
return config; |
|||
} |
|||
|
|||
private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { |
|||
DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); |
|||
dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); |
|||
dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); |
|||
dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); |
|||
dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, |
|||
org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); |
|||
|
|||
SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( |
|||
credentials.getPrivateKey(), null, credentials.getCertificateChain()); |
|||
|
|||
dtlsBuilder.setCertificateIdentityProvider( |
|||
new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), |
|||
Collections.singletonList(CertificateType.X_509))); |
|||
|
|||
dtlsBuilder.setAdvancedCertificateVerifier( |
|||
StaticNewAdvancedCertificateVerifier.builder() |
|||
.setTrustAllCertificates() |
|||
.build()); |
|||
|
|||
DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); |
|||
|
|||
CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); |
|||
endpointBuilder.setConfiguration(config); |
|||
endpointBuilder.setConnector(connector); |
|||
return endpointBuilder.build(); |
|||
} |
|||
|
|||
private KeyPair generateKeyPair() throws Exception { |
|||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); |
|||
kpg.initialize(256); |
|||
return kpg.generateKeyPair(); |
|||
} |
|||
|
|||
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { |
|||
X500Name subject = new X500Name(subjectDn); |
|||
Date now = new Date(); |
|||
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); |
|||
return new JcaX509CertificateConverter().getCertificate( |
|||
new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.valueOf(System.nanoTime()), now, expiry, |
|||
subject, kp.getPublic()) |
|||
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); |
|||
} |
|||
|
|||
private void writeCertPem(Path path, X509Certificate cert) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private Configuration createServerConfig() { |
|||
Configuration config = new Configuration(); |
|||
config.set(CoapConfig.MAX_RETRANSMIT, 2); |
|||
config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); |
|||
return config; |
|||
} |
|||
|
|||
private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { |
|||
try { |
|||
Configuration clientConfig = new Configuration(); |
|||
clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); |
|||
clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); |
|||
clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); |
|||
clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); |
|||
clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); |
|||
|
|||
DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); |
|||
clientDtls.setAdvancedCertificateVerifier( |
|||
StaticNewAdvancedCertificateVerifier.builder() |
|||
.setTrustedCertificates(trustedCert) |
|||
.build()); |
|||
|
|||
DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); |
|||
CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() |
|||
.setConfiguration(clientConfig) |
|||
.setConnector(clientConnector) |
|||
.build(); |
|||
|
|||
CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); |
|||
client.setEndpoint(clientEndpoint); |
|||
client.setTimeout((long) 5000); |
|||
|
|||
try { |
|||
clientEndpoint.start(); |
|||
return client.get(); |
|||
} finally { |
|||
client.shutdown(); |
|||
clientEndpoint.destroy(); |
|||
} |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private int findAvailablePort() throws Exception { |
|||
try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { |
|||
return socket.getLocalPort(); |
|||
} |
|||
} |
|||
|
|||
private static class TestResource extends CoapResource { |
|||
TestResource() { |
|||
super(TEST_RESOURCE_PATH); |
|||
} |
|||
|
|||
@Override |
|||
public void handleGET(CoapExchange exchange) { |
|||
exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,246 @@ |
|||
/** |
|||
* 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.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.MockedConstruction; |
|||
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.net.InetSocketAddress; |
|||
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.doThrow; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.mockConstruction; |
|||
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); |
|||
|
|||
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
|||
TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); |
|||
when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); |
|||
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); |
|||
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
|||
endpointsList.add(mockDtlsEndpoint); |
|||
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
|||
|
|||
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
|||
|
|||
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class); |
|||
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class, |
|||
(builder, context) -> { |
|||
when(builder.build()).thenReturn(mockNewEndpoint); |
|||
when(builder.setConfiguration(any())).thenReturn(builder); |
|||
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); |
|||
})) { |
|||
|
|||
// WHEN
|
|||
ReflectionTestUtils.invokeMethod(coapServerService, "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(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { |
|||
// GIVEN
|
|||
when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); |
|||
|
|||
DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); |
|||
when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); |
|||
when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); |
|||
|
|||
ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); |
|||
ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); |
|||
|
|||
List<Endpoint> endpointsList = new CopyOnWriteArrayList<>(); |
|||
endpointsList.add(mockDtlsEndpoint); |
|||
when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); |
|||
|
|||
CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); |
|||
doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); |
|||
|
|||
try (MockedConstruction<DTLSConnector> dtlsMock = mockConstruction(DTLSConnector.class); |
|||
MockedConstruction<CoapEndpoint.Builder> builderMock = mockConstruction(CoapEndpoint.Builder.class, |
|||
(builder, context) -> { |
|||
when(builder.build()).thenReturn(mockNewEndpoint); |
|||
when(builder.setConfiguration(any())).thenReturn(builder); |
|||
when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); |
|||
})) { |
|||
|
|||
// WHEN
|
|||
coapServerService.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
reloadCallback.run(); |
|||
|
|||
// THEN - new resources cleaned up
|
|||
DTLSConnector constructedConnector = dtlsMock.constructed().get(0); |
|||
verify(mockNewEndpoint).destroy(); |
|||
verify(constructedConnector).destroy(); |
|||
assertThat(endpointsList).doesNotContain(mockNewEndpoint); |
|||
// Old endpoint was stopped to release port, then restored after new one failed
|
|||
verify(mockDtlsEndpoint).stop(); |
|||
verify(mockDtlsEndpoint).start(); |
|||
// Old fields preserved
|
|||
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); |
|||
assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
/** |
|||
* 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; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
class ResourceUtilsTest { |
|||
|
|||
@Test |
|||
public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { |
|||
assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("Unable to find resource"); |
|||
} |
|||
|
|||
@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,198 @@ |
|||
/** |
|||
* 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 before old server is stopped.
|
|||
// The old server should NOT be destroyed since the new server was never created.
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockBootstrapServer, never()).stop(); |
|||
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_thenOldServerRestarted() { |
|||
// 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
|
|||
// Old server is stopped (not destroyed) to release ports
|
|||
verify(mockBootstrapServer).stop(); |
|||
verify(mockBootstrapServer, never()).destroy(); |
|||
// The new server fails to start and is destroyed
|
|||
verify(mockNewServer).destroy(); |
|||
// Old server is restarted (not rebuilt from potentially stale credentials)
|
|||
verify(mockBootstrapServer).start(); |
|||
assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
/** |
|||
* 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.config; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
|
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class LwM2MTransportServerConfigDebounceTest { |
|||
|
|||
private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS"); |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig credentialsConfig; |
|||
|
|||
@Mock |
|||
private SslCredentialsConfig trustCredentialsConfig; |
|||
|
|||
private LwM2MTransportServerConfig config; |
|||
|
|||
@BeforeEach |
|||
public void setup() { |
|||
config = new LwM2MTransportServerConfig(); |
|||
ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig); |
|||
ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig); |
|||
} |
|||
|
|||
@AfterEach |
|||
public void teardown() { |
|||
config.destroy(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
|
|||
// Wait extra to confirm no second invocation
|
|||
await().during(DEBOUNCE_SECONDS + 1, SECONDS) |
|||
.atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
} |
|||
|
|||
@Test |
|||
public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() { |
|||
AtomicInteger callCount = new AtomicInteger(0); |
|||
config.registerServerReloadCallback(callCount::incrementAndGet); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); |
|||
|
|||
invokeScheduleServerReload(); |
|||
|
|||
await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) |
|||
.untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2)); |
|||
} |
|||
|
|||
private void invokeScheduleServerReload() { |
|||
ReflectionTestUtils.invokeMethod(config, "scheduleServerReload"); |
|||
} |
|||
|
|||
} |
|||
@ -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.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 stopped/destroyed if the new one fails to build.
|
|||
reloadCallback.run(); |
|||
|
|||
verify(mockLeshanServer, never()).stop(); |
|||
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); |
|||
|
|||
// Force getLhServer() to fail by returning null host
|
|||
when(mockConfig.getHost()).thenReturn(null); |
|||
|
|||
// 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,276 @@ |
|||
/** |
|||
* 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 org.bouncycastle.asn1.x500.X500Name; |
|||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; |
|||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; |
|||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; |
|||
import org.bouncycastle.util.io.pem.PemObject; |
|||
import org.bouncycastle.util.io.pem.PemWriter; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.junit.jupiter.api.io.TempDir; |
|||
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.PemSslCredentials; |
|||
import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; |
|||
|
|||
import javax.net.ssl.SSLContext; |
|||
import javax.net.ssl.SSLServerSocket; |
|||
import javax.net.ssl.SSLSocket; |
|||
import javax.net.ssl.TrustManager; |
|||
import javax.net.ssl.X509TrustManager; |
|||
import java.io.OutputStreamWriter; |
|||
import java.math.BigInteger; |
|||
import java.net.InetAddress; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.cert.Certificate; |
|||
import java.security.cert.X509Certificate; |
|||
import java.util.Date; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class MqttSslCertificateReloadIntegrationTest { |
|||
|
|||
@TempDir |
|||
Path tempDir; |
|||
|
|||
@Mock |
|||
private TransportService transportService; |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); |
|||
|
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctxA = getProviderSslContext(provider); |
|||
X509Certificate servedA; |
|||
try (SSLServerSocket ss = createServerSocket(ctxA)) { |
|||
servedA = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
SSLContext ctxB = getProviderSslContext(provider); |
|||
assertThat(ctxB).isNotSameAs(ctxA); |
|||
X509Certificate servedB; |
|||
try (SSLServerSocket ss = createServerSocket(ctxB)) { |
|||
servedB = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); |
|||
assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { |
|||
KeyPair keyPair = generateKeyPair(); |
|||
X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, cert); |
|||
writeKeyPem(keyFile, keyPair); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctx1 = getProviderSslContext(provider); |
|||
assertThat(ctx1).isNotNull(); |
|||
|
|||
credentialsConfig.onCertificateFileChanged(); |
|||
|
|||
SSLContext ctx2 = getProviderSslContext(provider); |
|||
assertThat(ctx2).isNotSameAs(ctx1); |
|||
|
|||
X509Certificate served; |
|||
try (SSLServerSocket ss = createServerSocket(ctx2)) { |
|||
served = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); |
|||
} |
|||
|
|||
@Test |
|||
public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { |
|||
KeyPair keyPairA = generateKeyPair(); |
|||
X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); |
|||
KeyPair keyPairB = generateKeyPair(); |
|||
X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); |
|||
KeyPair keyPairC = generateKeyPair(); |
|||
X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); |
|||
|
|||
Path certFile = tempDir.resolve("server-cert.pem"); |
|||
Path keyFile = tempDir.resolve("server-key.pem"); |
|||
writeCertPem(certFile, certA); |
|||
writeKeyPem(keyFile, keyPairA); |
|||
|
|||
SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); |
|||
MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); |
|||
|
|||
SSLContext ctx1 = getProviderSslContext(provider); |
|||
|
|||
writeCertPem(certFile, certB); |
|||
writeKeyPem(keyFile, keyPairB); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
SSLContext ctx2 = getProviderSslContext(provider); |
|||
|
|||
writeCertPem(certFile, certC); |
|||
writeKeyPem(keyFile, keyPairC); |
|||
credentialsConfig.onCertificateFileChanged(); |
|||
SSLContext ctx3 = getProviderSslContext(provider); |
|||
|
|||
assertThat(ctx1).isNotSameAs(ctx2); |
|||
assertThat(ctx2).isNotSameAs(ctx3); |
|||
|
|||
X509Certificate served; |
|||
try (SSLServerSocket ss = createServerSocket(ctx3)) { |
|||
served = doHandshakeAndGetServerCert(ss); |
|||
} |
|||
assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); |
|||
} |
|||
|
|||
private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { |
|||
PemSslCredentials pem = new PemSslCredentials(); |
|||
pem.setCertFile(certFile.toAbsolutePath().toString()); |
|||
pem.setKeyFile(keyFile.toAbsolutePath().toString()); |
|||
|
|||
SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); |
|||
config.setEnabled(true); |
|||
config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); |
|||
config.setPem(pem); |
|||
config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); |
|||
config.init(); |
|||
return config; |
|||
} |
|||
|
|||
private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { |
|||
MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); |
|||
ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); |
|||
ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); |
|||
ReflectionTestUtils.setField(provider, "transportService", transportService); |
|||
provider.afterSingletonsInstantiated(); |
|||
return provider; |
|||
} |
|||
|
|||
/** |
|||
* Triggers SSLContext creation through the provider's getSslHandler() path, |
|||
* then extracts the cached SSLContext for direct server socket use. |
|||
*/ |
|||
private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { |
|||
provider.getSslHandler(); |
|||
return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); |
|||
} |
|||
|
|||
private KeyPair generateKeyPair() throws Exception { |
|||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); |
|||
kpg.initialize(2048); |
|||
return kpg.generateKeyPair(); |
|||
} |
|||
|
|||
private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { |
|||
X500Name subject = new X500Name(subjectDn); |
|||
Date now = new Date(); |
|||
Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); |
|||
return new JcaX509CertificateConverter().getCertificate( |
|||
new JcaX509v3CertificateBuilder( |
|||
subject, BigInteger.valueOf(System.nanoTime()), now, expiry, |
|||
subject, kp.getPublic()) |
|||
.build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); |
|||
} |
|||
|
|||
private void writeCertPem(Path path, X509Certificate cert) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { |
|||
try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { |
|||
writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); |
|||
} |
|||
} |
|||
|
|||
private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { |
|||
return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); |
|||
} |
|||
|
|||
private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { |
|||
Thread acceptor = new Thread(() -> { |
|||
try (var conn = serverSocket.accept()) { |
|||
conn.getInputStream().read(); |
|||
} catch (Exception ignored) {} |
|||
}); |
|||
acceptor.setDaemon(true); |
|||
acceptor.start(); |
|||
|
|||
SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); |
|||
clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); |
|||
|
|||
try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() |
|||
.createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { |
|||
client.setSoTimeout(5000); |
|||
client.startHandshake(); |
|||
|
|||
Certificate[] peerCerts = client.getSession().getPeerCertificates(); |
|||
assertThat(peerCerts).isNotEmpty(); |
|||
return (X509Certificate) peerCerts[0]; |
|||
} finally { |
|||
acceptor.join(5000); |
|||
} |
|||
} |
|||
|
|||
private static class TrustAllManager implements X509TrustManager { |
|||
|
|||
@Override |
|||
public void checkClientTrusted(X509Certificate[] chain, String authType) { |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public void checkServerTrusted(X509Certificate[] chain, String authType) { |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public X509Certificate[] getAcceptedIssuers() { |
|||
return new X509Certificate[0]; |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -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.List; |
|||
import java.util.concurrent.CopyOnWriteArrayList; |
|||
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_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
ArgumentCaptor<Runnable> callbackCaptor = ArgumentCaptor.forClass(Runnable.class); |
|||
verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); |
|||
Runnable reloadCallback = callbackCaptor.getValue(); |
|||
|
|||
SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(initialContext).isNotNull(); |
|||
|
|||
reloadCallback.run(); |
|||
|
|||
// After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free.
|
|||
SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextAfterReload).isNotNull(); |
|||
assertThat(contextAfterReload).isNotSameAs(initialContext); |
|||
|
|||
SslHandler handler = sslHandlerProvider.getSslHandler(); |
|||
assertThat(handler).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception { |
|||
sslHandlerProvider.afterSingletonsInstantiated(); |
|||
|
|||
SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextBefore).isNotNull(); |
|||
|
|||
CountDownLatch startLatch = new CountDownLatch(1); |
|||
CountDownLatch doneLatch = new CountDownLatch(5); |
|||
List<SslHandler> handlers = new CopyOnWriteArrayList<>(); |
|||
|
|||
for (int i = 0; i < 5; i++) { |
|||
new Thread(() -> { |
|||
try { |
|||
startLatch.await(); |
|||
handlers.add(sslHandlerProvider.getSslHandler()); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException(e); |
|||
} finally { |
|||
doneLatch.countDown(); |
|||
} |
|||
}).start(); |
|||
} |
|||
|
|||
startLatch.countDown(); |
|||
boolean completed = doneLatch.await(5, TimeUnit.SECONDS); |
|||
|
|||
assertThat(completed).isTrue(); |
|||
assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull()); |
|||
// Concurrent handshakes read the same pre-built context without the old sync bottleneck.
|
|||
SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); |
|||
assertThat(contextAfter).isSameAs(contextBefore); |
|||
} |
|||
|
|||
@Test |
|||
public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() { |
|||
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).isNotNull(); |
|||
assertThat(contextAfterReload).isNotSameAs(initialContext); |
|||
} |
|||
|
|||
@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,299 @@ |
|||
/** |
|||
* 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.io.InputStream; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.MessageDigest; |
|||
import java.util.ArrayList; |
|||
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_seconds: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 file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); |
|||
continue; |
|||
} |
|||
|
|||
// Register all configured paths, including those that don't exist yet — the watcher uses
|
|||
// mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are
|
|||
// picked up and trigger a reload on the next poll.
|
|||
List<Path> pathsToWatch = new ArrayList<>(filePaths.size()); |
|||
for (Path filePath : filePaths) { |
|||
if (filePath == null) { |
|||
continue; |
|||
} |
|||
pathsToWatch.add(filePath); |
|||
if (!Files.exists(filePath)) { |
|||
log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears", |
|||
filePath, config.getName()); |
|||
} |
|||
} |
|||
|
|||
if (!pathsToWatch.isEmpty()) { |
|||
registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged); |
|||
log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch); |
|||
} |
|||
|
|||
} 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; |
|||
} |
|||
|
|||
// Capture mtimes and checksums together before the callback runs.
|
|||
// Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll.
|
|||
Map<Path, Long> currentModifiedTimes = new HashMap<>(); |
|||
Map<Path, String> currentChecksums = new HashMap<>(); |
|||
StringBuilder combined = new StringBuilder(); |
|||
for (Path path : paths) { |
|||
currentModifiedTimes.put(path, getLastModifiedTime(path)); |
|||
String checksum = calculateChecksum(path); |
|||
currentChecksums.put(path, checksum); |
|||
if (!combined.isEmpty()) { |
|||
combined.append("|"); |
|||
} |
|||
combined.append(path).append("=").append(checksum); |
|||
} |
|||
String combinedChecksum = combined.toString(); |
|||
|
|||
// Build old combined checksum for comparison
|
|||
StringBuilder oldCombined = new StringBuilder(); |
|||
for (Path path : paths) { |
|||
if (!oldCombined.isEmpty()) { |
|||
oldCombined.append("|"); |
|||
} |
|||
oldCombined.append(path).append("=").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, currentModifiedTimes.get(path)); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { |
|||
// File content has changed since the last failure - reset and retry
|
|||
consecutiveFailures = 0; |
|||
failedCombinedChecksum = null; |
|||
} |
|||
|
|||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { |
|||
// Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle
|
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, currentModifiedTimes.get(path)); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
log.info("Certificate change detected for: {}. Triggering reload...", name); |
|||
reloadCallback.run(); |
|||
for (Path path : paths) { |
|||
lastModifiedMap.put(path, currentModifiedTimes.get(path)); |
|||
lastChecksumMap.put(path, currentChecksums.get(path)); |
|||
} |
|||
consecutiveFailures = 0; |
|||
failedCombinedChecksum = null; |
|||
} catch (Exception e) { |
|||
consecutiveFailures++; |
|||
failedCombinedChecksum = combinedChecksum; |
|||
// Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries
|
|||
// (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum).
|
|||
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[] buf = new byte[8192]; |
|||
try (InputStream is = Files.newInputStream(path)) { |
|||
int bytesRead; |
|||
while ((bytesRead = is.read(buf)) != -1) { |
|||
md.update(buf, 0, bytesRead); |
|||
} |
|||
} |
|||
return Base64.getEncoder().encodeToString(md.digest()); |
|||
} catch (Exception e) { |
|||
log.warn("Failed to calculate checksum for certificate file: {}", path, e); |
|||
return ""; |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,185 @@ |
|||
/** |
|||
* 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.assertj.core.api.Assertions.assertThatThrownBy; |
|||
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_thenShouldRethrowAndNotCallCallbacks() throws Exception { |
|||
AtomicInteger callbackCount = new AtomicInteger(0); |
|||
|
|||
config.registerReloadCallback(callbackCount::incrementAndGet); |
|||
config.setCredentials(mockCredentials); |
|||
|
|||
doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); |
|||
|
|||
assertThatThrownBy(() -> config.onCertificateFileChanged()) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("Failed to reload SSL credentials"); |
|||
|
|||
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,355 @@ |
|||
/** |
|||
* 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.nio.file.attribute.FileTime; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static java.util.concurrent.TimeUnit.MILLISECONDS; |
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
|
|||
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(); |
|||
} |
|||
} |
|||
|
|||
private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { |
|||
Files.writeString(path, content); |
|||
await().atMost(2, SECONDS) |
|||
.pollInterval(10, MILLISECONDS) |
|||
.until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); |
|||
} |
|||
|
|||
private long mtime(Path path) throws IOException { |
|||
return Files.getLastModifiedTime(path).toMillis(); |
|||
} |
|||
|
|||
@Test |
|||
public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
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); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
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); |
|||
|
|||
long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L; |
|||
Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime)); |
|||
|
|||
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); |
|||
|
|||
Files.delete(certFile); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { |
|||
certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); |
|||
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); |
|||
ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler); |
|||
|
|||
certificateReloadManager.destroy(); |
|||
|
|||
assertThat(scheduler.isShutdown()).isTrue(); |
|||
assertThat(scheduler.isTerminated()).isTrue(); |
|||
} |
|||
|
|||
@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"); |
|||
|
|||
AtomicInteger certReloadCount = new AtomicInteger(0); |
|||
AtomicInteger keyReloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); |
|||
certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(keyFile); |
|||
writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
assertThat(keyReloadCount.get()).isEqualTo(1); |
|||
assertThat(certReloadCount.get()).isEqualTo(0); |
|||
} |
|||
|
|||
@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); |
|||
|
|||
long baseline1 = mtime(certFile); |
|||
long baseline2 = mtime(cert2File); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); |
|||
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
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); |
|||
|
|||
long baseline1 = mtime(certFile); |
|||
long baseline2 = mtime(cert2File); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); |
|||
writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
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); |
|||
|
|||
Files.delete(certFile); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadCount.get()).isEqualTo(1); |
|||
|
|||
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadCount.get()).isEqualTo(2); |
|||
} |
|||
|
|||
@Test |
|||
public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { |
|||
AtomicInteger reloadCount = new AtomicInteger(0); |
|||
|
|||
certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); |
|||
|
|||
long baseline = mtime(certFile); |
|||
for (int i = 0; i < 5; i++) { |
|||
Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); |
|||
} |
|||
await().atMost(2, SECONDS) |
|||
.pollInterval(10, MILLISECONDS) |
|||
.until(() -> mtime(certFile) != baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
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); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
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(); |
|||
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); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
|
|||
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"); |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
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"); |
|||
} |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadAttempts.get()).isEqualTo(1); |
|||
|
|||
shouldFail.set(0); |
|||
long baseline2 = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
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"); |
|||
} |
|||
}); |
|||
|
|||
long baseline = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); |
|||
|
|||
for (int i = 0; i < 15; i++) { |
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
} |
|||
assertThat(reloadAttempts.get()).isEqualTo(10); |
|||
|
|||
shouldFail.set(0); |
|||
long baseline2 = mtime(certFile); |
|||
writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); |
|||
|
|||
ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); |
|||
assertThat(reloadAttempts.get()).isEqualTo(11); |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue