From c0cda91b922117558e27ced8f60755bd43beaf78 Mon Sep 17 00:00:00 2001 From: Richard Pringle Date: Tue, 2 Sep 2025 17:03:20 +0800 Subject: [PATCH] TestContainers.Mosquitto --- .github/workflows/cicd.yml | 1 + Directory.Packages.props | 1 + Testcontainers.dic | 1 + Testcontainers.lutconfig | 6 + Testcontainers.sln | 17 ++ docs/modules/index.md | 1 + src/Testcontainers.Mosquitto/.editorconfig | 1 + .../MosquittoBuilder.cs | 127 +++++++++++++ .../MosquittoConfiguration.cs | 75 ++++++++ .../MosquittoContainer.cs | 84 +++++++++ .../StringBuilderExtensions.cs | 11 ++ .../Testcontainers.Mosquitto.csproj | 12 ++ src/Testcontainers.Mosquitto/Usings.cs | 9 + .../Containers/DockerContainer.cs | 46 ++--- .../.editorconfig | 1 + .../MosquittoContainerTest.cs | 168 ++++++++++++++++++ .../PemCertificate.cs | 35 ++++ .../Testcontainers.Mosquitto.Tests.csproj | 20 +++ .../Testcontainers.Mosquitto.Tests/Usings.cs | 10 ++ 19 files changed, 603 insertions(+), 23 deletions(-) create mode 100644 Testcontainers.lutconfig create mode 100644 src/Testcontainers.Mosquitto/.editorconfig create mode 100644 src/Testcontainers.Mosquitto/MosquittoBuilder.cs create mode 100644 src/Testcontainers.Mosquitto/MosquittoConfiguration.cs create mode 100644 src/Testcontainers.Mosquitto/MosquittoContainer.cs create mode 100644 src/Testcontainers.Mosquitto/StringBuilderExtensions.cs create mode 100644 src/Testcontainers.Mosquitto/Testcontainers.Mosquitto.csproj create mode 100644 src/Testcontainers.Mosquitto/Usings.cs create mode 100644 tests/Testcontainers.Mosquitto.Tests/.editorconfig create mode 100644 tests/Testcontainers.Mosquitto.Tests/MosquittoContainerTest.cs create mode 100644 tests/Testcontainers.Mosquitto.Tests/PemCertificate.cs create mode 100644 tests/Testcontainers.Mosquitto.Tests/Testcontainers.Mosquitto.Tests.csproj create mode 100644 tests/Testcontainers.Mosquitto.Tests/Usings.cs diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e12f947b2..62e081325 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -69,6 +69,7 @@ jobs: { name: "Testcontainers.Milvus", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Minio", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.MongoDb", runs-on: "ubuntu-22.04" }, + { name: "Testcontainers.Mosquitto", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.MsSql", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.MySql", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Nats", runs-on: "ubuntu-22.04" }, diff --git a/Directory.Packages.props b/Directory.Packages.props index e6f7d7ac9..706c6df45 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -72,6 +72,7 @@ + diff --git a/Testcontainers.dic b/Testcontainers.dic index df1f16915..a8eab215e 100644 --- a/Testcontainers.dic +++ b/Testcontainers.dic @@ -17,6 +17,7 @@ lipsum ltsc memopt mongosh +mosquitto mycounter mydatabase myregistry diff --git a/Testcontainers.lutconfig b/Testcontainers.lutconfig new file mode 100644 index 000000000..596a86030 --- /dev/null +++ b/Testcontainers.lutconfig @@ -0,0 +1,6 @@ + + + true + true + 180000 + \ No newline at end of file diff --git a/Testcontainers.sln b/Testcontainers.sln index e09e2b093..d6c4f90c0 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -257,6 +257,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Xunit.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.XunitV3.Tests", "tests\Testcontainers.XunitV3.Tests\Testcontainers.XunitV3.Tests.csproj", "{B2E8B7FB-7D1E-4DD3-A25E-34DE4386B1EB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Mosquitto", "src\Testcontainers.Mosquitto\Testcontainers.Mosquitto.csproj", "{3A64B210-645C-4229-B089-5BB2AAFCF535}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Mosquitto.Tests", "tests\Testcontainers.Mosquitto.Tests\Testcontainers.Mosquitto.Tests.csproj", "{6314B57A-EE0C-4C3B-A9A9-64D68A47312A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -751,6 +755,14 @@ Global {B2E8B7FB-7D1E-4DD3-A25E-34DE4386B1EB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2E8B7FB-7D1E-4DD3-A25E-34DE4386B1EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2E8B7FB-7D1E-4DD3-A25E-34DE4386B1EB}.Release|Any CPU.Build.0 = Release|Any CPU + {3A64B210-645C-4229-B089-5BB2AAFCF535}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A64B210-645C-4229-B089-5BB2AAFCF535}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A64B210-645C-4229-B089-5BB2AAFCF535}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A64B210-645C-4229-B089-5BB2AAFCF535}.Release|Any CPU.Build.0 = Release|Any CPU + {6314B57A-EE0C-4C3B-A9A9-64D68A47312A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6314B57A-EE0C-4C3B-A9A9-64D68A47312A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6314B57A-EE0C-4C3B-A9A9-64D68A47312A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6314B57A-EE0C-4C3B-A9A9-64D68A47312A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -878,5 +890,10 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E901DF14-6F05-4FC2-825A-3055FAD33561} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {B2E8B7FB-7D1E-4DD3-A25E-34DE4386B1EB} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {3A64B210-645C-4229-B089-5BB2AAFCF535} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {6314B57A-EE0C-4C3B-A9A9-64D68A47312A} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {06AF4E8B-EB32-4C33-B1DD-923580E132D5} EndGlobalSection EndGlobal diff --git a/docs/modules/index.md b/docs/modules/index.md index 254a2d9fe..eb1298a15 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -54,6 +54,7 @@ await moduleNameContainer.StartAsync(); | Milvus | `milvusdb/milvus:v2.3.10` | [NuGet](https://www.nuget.org/packages/Testcontainers.Milvus) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Milvus) | | MinIO | `minio/minio:RELEASE.2023-01-31T02-24-19Z` | [NuGet](https://www.nuget.org/packages/Testcontainers.Minio) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Minio) | | MongoDB | `mongo:6.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MongoDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MongoDb) | +| Mosquitto | `eclipse-mosquitto:2.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.Mosquitto) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Mosquitto) | | MySQL | `mysql:8.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MySql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MySql) | | NATS | `nats:2.9` | [NuGet](https://www.nuget.org/packages/Testcontainers.Nats) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Nats) | | Neo4j | `neo4j:5.4` | [NuGet](https://www.nuget.org/packages/Testcontainers.Neo4j) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Neo4j) | diff --git a/src/Testcontainers.Mosquitto/.editorconfig b/src/Testcontainers.Mosquitto/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Mosquitto/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Mosquitto/MosquittoBuilder.cs b/src/Testcontainers.Mosquitto/MosquittoBuilder.cs new file mode 100644 index 000000000..b2477a782 --- /dev/null +++ b/src/Testcontainers.Mosquitto/MosquittoBuilder.cs @@ -0,0 +1,127 @@ +namespace TestContainers.Mosquitto; + +/// +[PublicAPI] +public class MosquittoBuilder : ContainerBuilder +{ + public const string MosquittoImage = "eclipse-mosquitto:2.0"; + + public const int TcpPort = 1883; + public const int TlsPort = 8883; + public const int WsPort = 80; + public const int WssPort = 443; + public const string CertificateFilePath = "/mosquitto/certs/server.pem"; + public const string CertificateKeyFilePath = "/mosquitto/certs/server-key.pem"; + + /// + /// Initializes a new instance of the class. + /// + public MosquittoBuilder() + : this(new MosquittoConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + public MosquittoBuilder(MosquittoConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override MosquittoConfiguration DockerResourceConfiguration { get; } + + /// + public override MosquittoContainer Build() + { + Validate(); + + var sb = new StringBuilder(); + sb.AppendUnixLine("per_listener_settings true"); + + sb.AppendUnixLine(); + sb.AppendUnixLine("# MQTT listener"); + sb.AppendUnixLine($"listener {TcpPort}"); + sb.AppendUnixLine("protocol mqtt"); + sb.AppendUnixLine("allow_anonymous true"); + + sb.AppendUnixLine(); + sb.AppendUnixLine("# WebSocket listener"); + sb.AppendUnixLine($"listener {WsPort}"); + sb.AppendUnixLine("protocol websockets"); + sb.AppendUnixLine("allow_anonymous true"); + + if (DockerResourceConfiguration.HasCertificate) + { + sb.AppendUnixLine(); + sb.AppendUnixLine("# MQTT listener (encrypted)"); + sb.AppendUnixLine($"listener {TlsPort}"); + sb.AppendUnixLine("protocol mqtt"); + sb.AppendUnixLine("allow_anonymous true"); + sb.AppendUnixLine($"certfile {CertificateFilePath}"); + sb.AppendUnixLine($"keyfile {CertificateKeyFilePath}"); + + sb.AppendUnixLine(); + sb.AppendUnixLine("# WebSocket listener (encrypted)"); + sb.AppendUnixLine($"listener {WssPort}"); + sb.AppendUnixLine("protocol websockets"); + sb.AppendUnixLine("allow_anonymous true"); + sb.AppendUnixLine($"certfile {CertificateFilePath}"); + sb.AppendUnixLine($"keyfile {CertificateKeyFilePath}"); + } + + var config = Clone(DockerResourceConfiguration) + .WithResourceMapping(Encoding.UTF8.GetBytes(sb.ToString()), "/mosquitto/config/mosquitto.conf"); + + return new MosquittoContainer(config.DockerResourceConfiguration); + } + + + public MosquittoBuilder WithCertificate(string certificate, string certificateKey) + { + return Merge(DockerResourceConfiguration, new MosquittoConfiguration(certificate: certificate, certificateKey: certificateKey)) + .WithPortBinding(TlsPort, true) + .WithPortBinding(WssPort, true) + .WithResourceMapping(Encoding.UTF8.GetBytes(certificate), CertificateFilePath) + .WithResourceMapping(Encoding.UTF8.GetBytes(certificateKey), CertificateKeyFilePath); + } + + /// + protected override MosquittoBuilder Init() + { + var builder = base.Init() + .WithImage(MosquittoImage) + .WithPortBinding(TcpPort, true) + .WithPortBinding(WsPort, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged(@"mosquitto.*running")); + + return builder; + } + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration, "Certificate") + .ThrowIf(argument => 1.Equals(new[] { argument.Value.Certificate, argument.Value.CertificateKey }.Count(string.IsNullOrWhiteSpace)), argument => new ArgumentException($"Both {nameof(argument.Value.Certificate)} and {nameof(argument.Value.CertificateKey)} must be supplied if one is.", argument.Name)); + } + + /// + protected override MosquittoBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new MosquittoConfiguration(resourceConfiguration)); + } + + /// + protected override MosquittoBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new MosquittoConfiguration(resourceConfiguration)); + } + + /// + protected override MosquittoBuilder Merge(MosquittoConfiguration oldValue, MosquittoConfiguration newValue) + { + return new MosquittoBuilder(new MosquittoConfiguration(oldValue, newValue)); + } +} diff --git a/src/Testcontainers.Mosquitto/MosquittoConfiguration.cs b/src/Testcontainers.Mosquitto/MosquittoConfiguration.cs new file mode 100644 index 000000000..2957b33d0 --- /dev/null +++ b/src/Testcontainers.Mosquitto/MosquittoConfiguration.cs @@ -0,0 +1,75 @@ +namespace TestContainers.Mosquitto; + +/// +[PublicAPI] +public sealed class MosquittoConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public MosquittoConfiguration( + string certificate = null, + string certificateKey = null) + { + Certificate = certificate; + CertificateKey = certificateKey; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public MosquittoConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public MosquittoConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public MosquittoConfiguration(MosquittoConfiguration resourceConfiguration) + : this(new MosquittoConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public MosquittoConfiguration(MosquittoConfiguration oldValue, MosquittoConfiguration newValue) + : base(oldValue, newValue) + { + Certificate = BuildConfiguration.Combine(oldValue.Certificate, newValue.Certificate); + CertificateKey = BuildConfiguration.Combine(oldValue.CertificateKey, newValue.CertificateKey); + + } + + /// + /// Gets the public certificate in PEM format. + /// + public string Certificate { get; } + + /// + /// Gets the private key associated with the certificate in PEM format. + /// + public string CertificateKey { get; } + + /// + /// Gets a value indicating whether both the certificate and the certificate key are provided. + /// + public bool HasCertificate => !string.IsNullOrWhiteSpace(Certificate) && !string.IsNullOrWhiteSpace(CertificateKey); +} \ No newline at end of file diff --git a/src/Testcontainers.Mosquitto/MosquittoContainer.cs b/src/Testcontainers.Mosquitto/MosquittoContainer.cs new file mode 100644 index 000000000..6d199c0bb --- /dev/null +++ b/src/Testcontainers.Mosquitto/MosquittoContainer.cs @@ -0,0 +1,84 @@ +namespace TestContainers.Mosquitto; + +/// +[PublicAPI] +public sealed class MosquittoContainer : DockerContainer +{ + private readonly bool _isSecure; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public MosquittoContainer(MosquittoConfiguration configuration) + : base(configuration) + { + _isSecure = configuration.HasCertificate; + } + + /// + /// Gets the MQTT endpoint. + /// + /// A TCP address in the format: tcp://hostname:port. + public string GetEndpoint() + { + return new UriBuilder(Uri.UriSchemeNetTcp, Hostname, GetPort()).ToString(); + } + + /// + /// Gets the MQTT endpoint port. + /// + /// Exposed port for insecure MQQT connections. + public ushort GetPort() + { + return GetMappedPublicPort(MosquittoBuilder.TcpPort); + } + + /// + /// Gets the secure MQTT endpoint. + /// + /// A TCP address in the format: tcp://hostname:port. + public string GetSecureEndpoint() + { + ThrowIfNotSecure(); + return new UriBuilder(Uri.UriSchemeNetTcp, Hostname, GetMappedPublicPort(MosquittoBuilder.TlsPort)).ToString(); + } + + /// + /// Gets the secure MQTT endpoint port. + /// + /// Exposed port for secure MQTT connections. + public ushort GetSecurePort() + { + return GetMappedPublicPort(MosquittoBuilder.TlsPort); + } + + /// + /// Gets the WebSocket endpoint. + /// + /// A WS address in the format: ws://hostname:port. + public string GetWsEndpoint() + { + return new UriBuilder("ws", Hostname, GetMappedPublicPort(MosquittoBuilder.WsPort)).ToString(); + } + + /// + /// Gets the secure WebSocket endpoint. + /// + /// A WS address in the format: ws://hostname:port. + public string GetWssEndpoint() + { + ThrowIfNotSecure(); + return new UriBuilder("wss", Hostname, GetMappedPublicPort(MosquittoBuilder.WssPort)).ToString(); + } + + private void ThrowIfNotSecure() + { + if (_isSecure) + { + return; + } + + throw new InvalidOperationException("The container was not configured with TLS/SSL support."); + } +} diff --git a/src/Testcontainers.Mosquitto/StringBuilderExtensions.cs b/src/Testcontainers.Mosquitto/StringBuilderExtensions.cs new file mode 100644 index 000000000..566c1c627 --- /dev/null +++ b/src/Testcontainers.Mosquitto/StringBuilderExtensions.cs @@ -0,0 +1,11 @@ +namespace TestContainers.Mosquitto; + +internal static class StringBuilderExtensions +{ + public static StringBuilder AppendUnixLine(this StringBuilder sb, string value = "") + { + return sb + .Append(value) + .Append('\n'); + } +} diff --git a/src/Testcontainers.Mosquitto/Testcontainers.Mosquitto.csproj b/src/Testcontainers.Mosquitto/Testcontainers.Mosquitto.csproj new file mode 100644 index 000000000..76f602541 --- /dev/null +++ b/src/Testcontainers.Mosquitto/Testcontainers.Mosquitto.csproj @@ -0,0 +1,12 @@ + + + net8.0;net9.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Mosquitto/Usings.cs b/src/Testcontainers.Mosquitto/Usings.cs new file mode 100644 index 000000000..2fa5664d9 --- /dev/null +++ b/src/Testcontainers.Mosquitto/Usings.cs @@ -0,0 +1,9 @@ +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using System; +global using System.Linq; +global using System.Text; \ No newline at end of file diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 7162efd47..f36b86514 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -1,12 +1,5 @@ namespace DotNet.Testcontainers.Containers { - using System; - using System.Collections.Generic; - using System.Globalization; - using System.IO; - using System.Linq; - using System.Threading; - using System.Threading.Tasks; using Docker.DotNet; using Docker.DotNet.Models; using DotNet.Testcontainers.Clients; @@ -14,6 +7,13 @@ namespace DotNet.Testcontainers.Containers using DotNet.Testcontainers.Images; using JetBrains.Annotations; using Microsoft.Extensions.Logging; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; /// [PublicAPI] @@ -150,28 +150,28 @@ public string Hostname case "http": case "https": case "tcp": - { - return dockerEndpoint.Host; - } + { + return dockerEndpoint.Host; + } case "npipe": case "unix": - { - const string localhost = "127.0.0.1"; - - if (!Exists()) { - return localhost; - } + const string localhost = "127.0.0.1"; - if (!_client.IsRunningInsideDocker) - { - return localhost; - } + if (!Exists()) + { + return localhost; + } + + if (!_client.IsRunningInsideDocker) + { + return localhost; + } - var endpointSettings = _container.NetworkSettings.Networks.First().Value; - return endpointSettings.Gateway; - } + var endpointSettings = _container.NetworkSettings.Networks.First().Value; + return endpointSettings.Gateway; + } default: throw new InvalidOperationException($"Docker endpoint {dockerEndpoint} is not supported."); diff --git a/tests/Testcontainers.Mosquitto.Tests/.editorconfig b/tests/Testcontainers.Mosquitto.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Mosquitto.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Mosquitto.Tests/MosquittoContainerTest.cs b/tests/Testcontainers.Mosquitto.Tests/MosquittoContainerTest.cs new file mode 100644 index 000000000..708dee3ba --- /dev/null +++ b/tests/Testcontainers.Mosquitto.Tests/MosquittoContainerTest.cs @@ -0,0 +1,168 @@ +namespace TestContainers.Mosquitto.Tests; + +public abstract class MosquittoContainerTest : ContainerTest +{ + private IMqttClient _client; + + protected MosquittoContainerTest(ITestOutputHelper outputHelper, Func configure = null) + : base(outputHelper, configure) + { + } + + protected override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + + var mqttFactory = new MqttClientFactory(); + _client = mqttFactory.CreateMqttClient(); + } + + protected override async ValueTask DisposeAsyncCore() + { + await _client?.TryDisconnectAsync(MqttClientDisconnectOptionsReason.NormalDisconnection); + _client?.Dispose(); + + await base.DisposeAsyncCore(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task CanEstablishAConnection() + { + var result = await _client.ConnectAsync(GetClientOptions(), TestContext.Current.CancellationToken); + Assert.Equal(MqttClientConnectResultCode.Success, result.ResultCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PublishedMessageIsReceived() + { + const string topic = "test/topic"; + const string payload = "Hello, MQTT!"; + + var tcs = new TaskCompletionSource(); + + var options = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(f => f.WithTopic(topic)) + .Build(); + + bool messageReceived = false; + + await _client.ConnectAsync(GetClientOptions(), TestContext.Current.CancellationToken); + _client.ApplicationMessageReceivedAsync += e => + { + Assert.Equal(topic, e.ApplicationMessage.Topic); + Assert.Equal(payload, e.ApplicationMessage.ConvertPayloadToString()); + messageReceived = true; + tcs.SetResult(); + return Task.CompletedTask; + }; + + var sub = await _client.SubscribeAsync(options, TestContext.Current.CancellationToken); + + await _client.PublishStringAsync(topic, payload, cancellationToken: TestContext.Current.CancellationToken); + await Task.WhenAny(tcs.Task, Task.Delay(-1, TestContext.Current.CancellationToken)); + + Assert.True(messageReceived); + } + + protected abstract MqttClientOptions GetClientOptions(); + + [UsedImplicitly] + public sealed class MosquittoTcpAnonymousConfiguration : MosquittoContainerTest + { + public MosquittoTcpAnonymousConfiguration(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override MqttClientOptions GetClientOptions() + { + var builder = new MqttClientOptionsBuilder() + .WithClientId($"testcontainers.mosquitto-{Guid.NewGuid()}") + .WithCleanStart() + .WithTcpServer(Container.Hostname, Container.GetPort()); + + return builder.Build(); + } + } + + [UsedImplicitly] + public sealed class MosquittoTcpEncryptedAnonymousConfiguration : MosquittoContainerTest + { + public MosquittoTcpEncryptedAnonymousConfiguration(ITestOutputHelper outputHelper) + : base(outputHelper, builder => builder.WithCertificate(PemCertificate.Instance.Certificate, PemCertificate.Instance.CertificateKey)) + { + } + + protected override MqttClientOptions GetClientOptions() + { + var builder = new MqttClientOptionsBuilder() + .WithTlsOptions(o => + o.UseTls() + .WithAllowUntrustedCertificates() + .WithIgnoreCertificateChainErrors() + .WithIgnoreCertificateRevocationErrors() + .WithCertificateValidationHandler(context => + { + Assert.NotNull(context.Certificate); + return true; + }) + ) + .WithClientId($"testcontainers.mosquitto-{Guid.NewGuid()}") + .WithCleanStart() + .WithTcpServer(Container.Hostname, Container.GetSecurePort()); + + return builder.Build(); + } + } + + [UsedImplicitly] + public sealed class MosquittoWebSocketAnonymousConfiguration : MosquittoContainerTest + { + public MosquittoWebSocketAnonymousConfiguration(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override MqttClientOptions GetClientOptions() + { + var builder = new MqttClientOptionsBuilder() + .WithClientId($"testcontainers.mosquitto-{Guid.NewGuid()}") + .WithCleanStart() + .WithWebSocketServer(o => o.WithUri(Container.GetWsEndpoint())); + + return builder.Build(); + } + } + + [UsedImplicitly] + public sealed class MosquittoWebSocketSecureAnonymousConfiguration : MosquittoContainerTest + { + public MosquittoWebSocketSecureAnonymousConfiguration(ITestOutputHelper outputHelper) + : base(outputHelper, builder => builder.WithCertificate(PemCertificate.Instance.Certificate, PemCertificate.Instance.CertificateKey)) + { + } + + protected override MqttClientOptions GetClientOptions() + { + var builder = new MqttClientOptionsBuilder() + .WithTlsOptions(o => + o.UseTls() + .WithAllowUntrustedCertificates() + .WithIgnoreCertificateChainErrors() + .WithIgnoreCertificateRevocationErrors() + .WithCertificateValidationHandler(context => + { + Assert.NotNull(context.Certificate); + return true; + }) + ) + .WithClientId($"testcontainers.mosquitto-{Guid.NewGuid()}") + .WithCleanStart() + .WithWebSocketServer(o => o.WithUri(Container.GetWssEndpoint())); + + return builder.Build(); + } + } +} diff --git a/tests/Testcontainers.Mosquitto.Tests/PemCertificate.cs b/tests/Testcontainers.Mosquitto.Tests/PemCertificate.cs new file mode 100644 index 000000000..e549575d3 --- /dev/null +++ b/tests/Testcontainers.Mosquitto.Tests/PemCertificate.cs @@ -0,0 +1,35 @@ +namespace Testcontainers.Mosquitto.Tests; + +public sealed class PemCertificate +{ + static PemCertificate() + { + } + + private PemCertificate(string commonName) + { + using var rsa = RSA.Create(2048); + + var subjectName = new X500DistinguishedName($"CN={commonName}"); + + var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + using var certificate = request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + + CommonName = commonName; + Thumbprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); + Certificate = certificate.ExportCertificatePem(); + CertificateKey = rsa.ExportPkcs8PrivateKeyPem(); + } + + public static PemCertificate Instance { get; } + = new PemCertificate("localhost"); + + public string CommonName { get; } + + public string Thumbprint { get; } + + public string Certificate { get; } + + public string CertificateKey { get; } +} diff --git a/tests/Testcontainers.Mosquitto.Tests/Testcontainers.Mosquitto.Tests.csproj b/tests/Testcontainers.Mosquitto.Tests/Testcontainers.Mosquitto.Tests.csproj new file mode 100644 index 000000000..03974b01b --- /dev/null +++ b/tests/Testcontainers.Mosquitto.Tests/Testcontainers.Mosquitto.Tests.csproj @@ -0,0 +1,20 @@ + + + net9.0 + false + false + Exe + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.Mosquitto.Tests/Usings.cs b/tests/Testcontainers.Mosquitto.Tests/Usings.cs new file mode 100644 index 000000000..df23153f5 --- /dev/null +++ b/tests/Testcontainers.Mosquitto.Tests/Usings.cs @@ -0,0 +1,10 @@ +global using DotNet.Testcontainers.Commons; +global using JetBrains.Annotations; +global using MQTTnet; +global using System; +global using System.Security.Cryptography; +global using System.Security.Cryptography.X509Certificates; +global using System.Threading.Tasks; +global using Testcontainers.Mosquitto.Tests; +global using Testcontainers.Xunit; +global using Xunit;