diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/helpers/ProxyServerTools.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/helpers/ProxyServerTools.java new file mode 100644 index 0000000000..ea7d656192 --- /dev/null +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/helpers/ProxyServerTools.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package tests.integration.com.microsoft.azure.sdk.iot.helpers; + +import com.github.monkeywie.proxyee.server.HttpProxyServer; + +import java.util.concurrent.TimeUnit; + +/** + * Helpers for the local HTTP proxy servers that the proxy related integration tests run their traffic through. + */ +public class ProxyServerTools +{ + private static final int PROXY_START_TIMEOUT_SECONDS = 30; + + /** + * Start the provided proxy server on the provided port and block until it is actually listening on that port. + * + * {@link HttpProxyServer#startAsync(int)} only initiates the bind, so tests that don't wait on the returned future + * can start sending traffic to the proxy before it is listening. When that happens, the client under test gets a + * "Connection refused" instead of a working proxy. + * + * @param proxyServer the proxy server to start. + * @param port the port for the proxy server to listen on. + * @throws Exception if the proxy server could not be started within {@link #PROXY_START_TIMEOUT_SECONDS} seconds. + */ + public static void startProxyServer(HttpProxyServer proxyServer, int port) throws Exception + { + proxyServer.startAsync(port).toCompletableFuture().get(PROXY_START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/FileUploadTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/FileUploadTests.java index f313fe8f7b..f0ed962268 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/FileUploadTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/FileUploadTests.java @@ -36,6 +36,7 @@ import org.junit.runners.Parameterized; import tests.integration.com.microsoft.azure.sdk.iot.helpers.annotations.FlakeyTest; import tests.integration.com.microsoft.azure.sdk.iot.helpers.IntegrationTest; +import tests.integration.com.microsoft.azure.sdk.iot.helpers.ProxyServerTools; import tests.integration.com.microsoft.azure.sdk.iot.helpers.TestConstants; import tests.integration.com.microsoft.azure.sdk.iot.helpers.TestDeviceIdentity; import tests.integration.com.microsoft.azure.sdk.iot.helpers.Tools; @@ -147,12 +148,12 @@ public FileUploadState() } @BeforeClass - public static void startProxy() + public static void startProxy() throws Exception { HttpProxyServerConfig config = new HttpProxyServerConfig(); config.setHandleSsl(false); proxyServer = new HttpProxyServer().serverConfig(config); - proxyServer.startAsync(testProxyPort); + ProxyServerTools.startProxyServer(proxyServer, testProxyPort); } @AfterClass diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/MultiplexingClientTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/MultiplexingClientTests.java index d9889107e9..e892257cab 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/MultiplexingClientTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/MultiplexingClientTests.java @@ -203,12 +203,12 @@ public void tearDownTest() } @BeforeClass - public static void startProxy() + public static void startProxy() throws Exception { HttpProxyServerConfig config = new HttpProxyServerConfig(); config.setHandleSsl(false); proxyServer = new HttpProxyServer().serverConfig(config); - proxyServer.startAsync(testProxyPort); + ProxyServerTools.startProxyServer(proxyServer, testProxyPort); } @AfterClass diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/TokenRenewalTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/TokenRenewalTests.java index e610ff07b9..7074532677 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/TokenRenewalTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/TokenRenewalTests.java @@ -85,12 +85,12 @@ public static void setUp() } @BeforeClass - public static void startProxy() + public static void startProxy() throws Exception { HttpProxyServerConfig config = new HttpProxyServerConfig(); config.setHandleSsl(false); proxyServer = new HttpProxyServer().serverConfig(config); - proxyServer.startAsync(testProxyPort); + ProxyServerTools.startProxyServer(proxyServer, testProxyPort); } @AfterClass diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java index ea8ec9ed85..2b3222c1fc 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java @@ -12,6 +12,7 @@ import com.microsoft.azure.sdk.iot.service.registry.Module; import com.microsoft.azure.sdk.iot.service.registry.RegistryClient; import lombok.extern.slf4j.Slf4j; +import org.junit.After; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -23,6 +24,7 @@ import tests.integration.com.microsoft.azure.sdk.iot.helpers.annotations.StandardTierHubOnlyTest; import javax.net.ssl.SSLContext; +import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; import java.util.*; @@ -97,6 +99,24 @@ public class ConnectionTestInstance { public IotHubClientProtocol protocol; public TestIdentity identity; + + // ECC identities are created by this test rather than taken from the shared pool, and they carry a + // certificate that only this test knows about, so they must never be recycled back into that pool. This is + // recorded as soon as the device is registered, rather than being derived from the identity, because the + // rest of setupEccDevice can fail after that registration has already happened. Deleting the device also + // deletes any module underneath it, so the module does not need to be tracked separately. + private String eccDeviceIdToDelete; + + // What teardown still owns, kept separate from the public identity field above. dispose() clears these but + // deliberately leaves identity set, because a test thread abandoned by the JUnit timeout may still read it. + // Clearing them is what makes dispose() safe to run more than once: without it a second run would close the + // same client twice and, worse, requeue the same identity into the shared pool twice. + private TestIdentity identityToDispose; + + // Set once teardown has run. Guarded by lifecycleLock along with the two fields above. + private boolean disposed; + + private final Object lifecycleLock = new Object(); public AuthenticationType authenticationType; public ClientType clientType; public boolean useHttpProxy; @@ -111,41 +131,54 @@ public ConnectionTestInstance(IotHubClientProtocol protocol, AuthenticationType this.useHttpProxyAuth = useHttpProxyAuth; } - public void setup() throws Exception + /** + * Configure this test's proxy settings on the given builder, if this variant uses a proxy at all. + * + *
Both setup paths go through here so that they cannot drift apart. They previously had separate copies of + * this logic, and the copy in setupEccDevice was missing the unauthenticated branch entirely.
+ * + * @param optionsBuilder The builder to apply the proxy settings to + */ + private void applyProxySettings(ClientOptions.ClientOptionsBuilder optionsBuilder) { - ClientOptions.ClientOptionsBuilder optionsBuilder = ClientOptions.builder(); - if (this.useHttpProxy) + if (!this.useHttpProxy) { - if (this.useHttpProxyAuth) - { - Proxy testProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(testProxyHostname, testProxyPort)); - optionsBuilder.proxySettings(new ProxySettings(testProxy, testProxyUser, testProxyPass.toCharArray())); - } - else - { - Proxy testProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(testProxyHostnameWithoutAuth, testProxyPortWithoutAuth)); - optionsBuilder.proxySettings(new ProxySettings(testProxy)); - } + return; + } + + if (this.useHttpProxyAuth) + { + Proxy testProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(testProxyHostname, testProxyPort)); + optionsBuilder.proxySettings(new ProxySettings(testProxy, testProxyUser, testProxyPass.toCharArray())); + } + else + { + Proxy testProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(testProxyHostnameWithoutAuth, testProxyPortWithoutAuth)); + optionsBuilder.proxySettings(new ProxySettings(testProxy)); } + } + + public void setup() throws Exception + { + ClientOptions.ClientOptionsBuilder optionsBuilder = ClientOptions.builder(); + applyProxySettings(optionsBuilder); if (clientType == ClientType.DEVICE_CLIENT) { - this.identity = Tools.getTestDevice(iotHubConnectionString, this.protocol, this.authenticationType, false, optionsBuilder); + trackForCleanup(Tools.getTestDevice(iotHubConnectionString, this.protocol, this.authenticationType, false, optionsBuilder)); } else if (clientType == ClientType.MODULE_CLIENT) { - this.identity = Tools.getTestModule(iotHubConnectionString, this.protocol, this.authenticationType , false, optionsBuilder); + trackForCleanup(Tools.getTestModule(iotHubConnectionString, this.protocol, this.authenticationType , false, optionsBuilder)); } + + disposeIfTeardownAlreadyRan(); } public void setupEccDevice() throws Exception { ClientOptions.ClientOptionsBuilder optionsBuilder = ClientOptions.builder(); - if (this.useHttpProxy) - { - Proxy testProxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(testProxyHostname, testProxyPort)); - optionsBuilder.proxySettings(new ProxySettings(testProxy, testProxyUser, testProxyPass.toCharArray())); - } + applyProxySettings(optionsBuilder); X509CertificateGenerator certificateGenerator = new X509CertificateGenerator(X509CertificateGenerator.CertificateAlgorithm.ECC); SSLContext sslContext = SSLContextBuilder.buildSSLContext(certificateGenerator.getX509Certificate(), certificateGenerator.getPrivateKey()); @@ -157,11 +190,12 @@ public void setupEccDevice() throws Exception eccDevice.setThumbprint(certificateGenerator.getX509Thumbprint(), certificateGenerator.getX509Thumbprint()); Tools.addDeviceWithRetry(new RegistryClient(iotHubConnectionString), eccDevice); + trackEccDeviceForCleanup(eccDevice.getDeviceId()); String deviceConnectionString = Tools.getDeviceConnectionString(iotHubConnectionString, eccDevice); - this.identity = new TestDeviceIdentity( + trackForCleanup(new TestDeviceIdentity( new DeviceClient(deviceConnectionString, testInstance.protocol, optionsBuilder.build()), - eccDevice); + eccDevice)); } else if (clientType == ClientType.MODULE_CLIENT) { @@ -171,24 +205,122 @@ else if (clientType == ClientType.MODULE_CLIENT) eccModule.setThumbprint(certificateGenerator.getX509Thumbprint(), certificateGenerator.getX509Thumbprint()); Tools.addDeviceWithRetry(new RegistryClient(iotHubConnectionString), eccDevice); + trackEccDeviceForCleanup(eccDevice.getDeviceId()); + Tools.addModuleWithRetry(new RegistryClient(iotHubConnectionString), eccModule); String moduleConnectionString = Tools.getDeviceConnectionString(iotHubConnectionString, eccDevice) + ";ModuleId=" + eccModule.getId(); - this.identity = new TestModuleIdentity( + trackForCleanup(new TestModuleIdentity( new ModuleClient(moduleConnectionString, testInstance.protocol, optionsBuilder.build()), eccDevice, - eccModule); + eccModule)); + } + + disposeIfTeardownAlreadyRan(); + } + + /** + * Hand an identity to teardown, and publish it for the test body to use. + * + * @param newIdentity The identity this test just acquired or created + */ + private void trackForCleanup(TestIdentity newIdentity) + { + // Published for the test body. Never cleared, so a thread the JUnit timeout abandoned can keep reading it. + this.identity = newIdentity; + + synchronized (lifecycleLock) + { + this.identityToDispose = newIdentity; } } + /** + * Hand a freshly registered ECC device to teardown, so it is removed from the registry even if the rest of + * setupEccDevice never completes. + * + * @param deviceId The device id that was just added to the registry + */ + private void trackEccDeviceForCleanup(String deviceId) + { + synchronized (lifecycleLock) + { + this.eccDeviceIdToDelete = deviceId; + } + } + + /** + * Dispose anything registered after teardown already ran. + * + *Every test in this class is bounded by {@code @Test(timeout = 60000)}. JUnit runs the test body on a + * separate thread and, when the timeout fires, abandons that thread while it is still running. {@code @After} + * is outside the timeout, so teardown can execute while setup on the abandoned thread has not finished + * acquiring its identity. Without this, the identity that setup goes on to produce would have no owner and + * would leak, which is precisely the leak this class is trying to stop.
+ * + *This does not wait for setup, in either direction. Blocking teardown on a setup that is itself hung - + * which is how these tests have actually timed out - would stall the rest of the run.
+ */ + private void disposeIfTeardownAlreadyRan() + { + boolean teardownAlreadyRan; + synchronized (lifecycleLock) + { + teardownAlreadyRan = this.disposed; + } + + if (teardownAlreadyRan) + { + dispose(); + } + } + + /** + * Close and dispose whatever this instance currently owns. + * + *Safe to call more than once, and safe to call concurrently with setup: it takes ownership of the tracked + * fields and clears them, so a second call finds only what was registered since the first.
+ */ public void dispose() { - if (this.identity != null && this.identity.getClient() != null) + TestIdentity identityToClean; + String eccDeviceIdToClean; + + synchronized (lifecycleLock) + { + this.disposed = true; + + identityToClean = this.identityToDispose; + eccDeviceIdToClean = this.eccDeviceIdToDelete; + + this.identityToDispose = null; + this.eccDeviceIdToDelete = null; + } + + if (identityToClean != null && identityToClean.getClient() != null) { - this.identity.getClient().close(); + identityToClean.getClient().close(); } - Tools.disposeTestIdentity(this.identity, iotHubConnectionString); + if (eccDeviceIdToClean != null) + { + // Recycling this identity would hand a device carrying a certificate that no other test knows about to + // the next test that takes an x509 identity from the shared pool, so delete it instead. This runs even + // when the identity was never finished being built, because the device is in the registry from the + // moment it is registered, whether or not the rest of the setup succeeded. + try + { + Tools.getRegistyManager(iotHubConnectionString).removeDevice(eccDeviceIdToClean); + } + catch (IOException | IotHubException e) + { + log.error("Failed to clean up ECC test device {}", eccDeviceIdToClean, e); + } + } + else if (identityToClean != null) + { + Tools.disposeTestIdentity(identityToClean, iotHubConnectionString); + } } } @@ -208,18 +340,28 @@ public void dispose() protected static final String testProxyPass = "1234"; // lgtm @BeforeClass - public static void startProxy() + public static void startProxy() throws Exception { HttpProxyServerConfig config = new HttpProxyServerConfig(); config.setAuthenticationProvider(new BasicProxyAuthenticator(testProxyUser, testProxyPass)); config.setHandleSsl(false); proxyServer = new HttpProxyServer().serverConfig(config); - proxyServer.startAsync(testProxyPort); + ProxyServerTools.startProxyServer(proxyServer, testProxyPort); HttpProxyServerConfig configWithoutAuth = new HttpProxyServerConfig(); configWithoutAuth.setHandleSsl(false); proxyServerWithoutAuth = new HttpProxyServer().serverConfig(configWithoutAuth); - proxyServerWithoutAuth.startAsync(testProxyPortWithoutAuth); + ProxyServerTools.startProxyServer(proxyServerWithoutAuth, testProxyPortWithoutAuth); + } + + // Without this, every test in this class leaks the client it opened. Those clients keep retrying their + // connections for the rest of the JVM's life, and the ones configured with proxy settings keep retrying through + // the proxies this class runs locally, which competes with the tests that are still running. Once stopProxy has + // closed those proxies they retry against a dead port instead, for the remainder of the job. + @After + public void disposeTestInstance() + { + testInstance.dispose(); } @AfterClass @@ -273,16 +415,21 @@ private static void logConnectionStatusChanges(InternalClient client) public void CanOpenConnection() throws Exception { testInstance.setup(); - logConnectionStatusChanges(testInstance.identity.getClient()); - testInstance.identity.getClient().open(true); + + // Held locally rather than read back off testInstance for each call. The @After that disposes this instance + // runs outside this method's timeout, so it can execute while this thread is still here after a timeout. + InternalClient client = testInstance.identity.getClient(); + + logConnectionStatusChanges(client); + client.open(true); // deviceClient.open() is a no-op on HTTP, so a message needs to be sent to actually test opening the connection if (testInstance.protocol == HTTPS) { - testInstance.identity.getClient().sendEvent(new Message("some message")); + client.sendEvent(new Message("some message")); } - testInstance.identity.getClient().close(); + client.close(); } @IotHubTest @@ -303,16 +450,18 @@ public void CanOpenConnectionWithECCCertificates() throws Exception testInstance.setupEccDevice(); - logConnectionStatusChanges(testInstance.identity.getClient()); - testInstance.identity.getClient().open(true); + InternalClient client = testInstance.identity.getClient(); + + logConnectionStatusChanges(client); + client.open(true); // deviceClient.open() is a no-op on HTTP, so a message needs to be sent to actually test opening the connection if (testInstance.protocol == HTTPS) { - testInstance.identity.getClient().sendEvent(new Message("some message")); + client.sendEvent(new Message("some message")); } - testInstance.identity.getClient().close(); + client.close(); } @Test(timeout = 60000) // 1 minute @@ -360,10 +509,22 @@ public void CanOpenMultiplexingConnection() throws Exception multiplexingClient.registerDeviceClients(testClients); multiplexingClient.open(true); - multiplexingClient.close(); } finally { + // Closing here rather than after open() so that a failed or timed out open still gives the client and the + // three device clients registered to it back. Otherwise they keep retrying for the life of the JVM, and + // the proxied variants keep retrying through the proxies this class runs locally. + try + { + multiplexingClient.close(); + } + catch (Exception e) + { + // Swallowed so it cannot mask whatever the test itself threw. + log.error("Failed to close the multiplexing client", e); + } + Tools.disposeTestIdentities(testIdentities, iotHubConnectionString); } } diff --git a/provisioning/provisioning-device-client/src/test/java/com/microsoft/azure/sdk/iot/provisioning/device/internal/contract/amqp/ContractAPIAmqpTest.java b/provisioning/provisioning-device-client/src/test/java/com/microsoft/azure/sdk/iot/provisioning/device/internal/contract/amqp/ContractAPIAmqpTest.java index ef4f44bc06..b02cd55070 100644 --- a/provisioning/provisioning-device-client/src/test/java/com/microsoft/azure/sdk/iot/provisioning/device/internal/contract/amqp/ContractAPIAmqpTest.java +++ b/provisioning/provisioning-device-client/src/test/java/com/microsoft/azure/sdk/iot/provisioning/device/internal/contract/amqp/ContractAPIAmqpTest.java @@ -67,9 +67,6 @@ public class ContractAPIAmqpTest @Mocked Map