diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 8333e0e38..82b4b70d8 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -90,6 +90,7 @@ jobs: { name: "Testcontainers.Redpanda", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.ServiceBus", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Sftp", runs-on: "ubuntu-22.04" }, + { name: "Testcontainers.Toxiproxy", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Weaviate", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.WebDriver", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Xunit", runs-on: "ubuntu-22.04" }, diff --git a/Directory.Packages.props b/Directory.Packages.props index be2ddadfd..6badc05f7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -85,5 +85,6 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 671d21083..0676ed2a3 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -249,6 +249,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.Toxiproxy", "src\Testcontainers.Toxiproxy\Testcontainers.Toxiproxy.csproj", "{65A47BA4-4DC8-4206-9B00-CBC87FC944FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Toxiproxy.Tests", "tests\Testcontainers.Toxiproxy.Tests\Testcontainers.Toxiproxy.Tests.csproj", "{10726AAA-E93F-4B40-A05E-28308423DABE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -727,6 +731,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 + {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65A47BA4-4DC8-4206-9B00-CBC87FC944FC}.Release|Any CPU.Build.0 = Release|Any CPU + {10726AAA-E93F-4B40-A05E-28308423DABE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10726AAA-E93F-4B40-A05E-28308423DABE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10726AAA-E93F-4B40-A05E-28308423DABE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10726AAA-E93F-4B40-A05E-28308423DABE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -850,5 +862,7 @@ 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} + {65A47BA4-4DC8-4206-9B00-CBC87FC944FC} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {10726AAA-E93F-4B40-A05E-28308423DABE} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/docs/modules/index.md b/docs/modules/index.md index f04973794..6c715d6f8 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -69,6 +69,7 @@ await moduleNameContainer.StartAsync(); | Redpanda | `docker.redpanda.com/redpandadata/redpanda:v22.2.1` | [NuGet](https://www.nuget.org/packages/Testcontainers.Redpanda) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Redpanda) | | Sftp | `atmoz/sftp:alpine` | [NuGet](https://www.nuget.org/packages/Testcontainers.Sftp) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Sftp) | | SQL Server | `mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04` | [NuGet](https://www.nuget.org/packages/Testcontainers.MsSql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MsSql) | +| Toxiproxy | `ghcr.io/shopify/toxiproxy` | [NuGet](https://www.nuget.org/packages/Testcontainers.Toxiproxy) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Toxiproxy) | | Weaviate | `semitechnologies/weaviate:1.26.14` | [NuGet](https://www.nuget.org/packages/Testcontainers.Weaviate) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Weaviate) | | WebDriver | `selenium/standalone-chrome:110.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.WebDriver) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.WebDriver) | diff --git a/docs/modules/toxiproxy.md b/docs/modules/toxiproxy.md new file mode 100644 index 000000000..298ea92f9 --- /dev/null +++ b/docs/modules/toxiproxy.md @@ -0,0 +1,57 @@ +# Toxiproxy + +[Toxiproxy](https://github.com/Shopify/toxiproxy) is a proxy to simulate network failure for testing. It can simulate latency, timeouts, bandwidth limits, and more between services. + +This module integrates [Toxiproxy.Net](https://github.com/mdevilliers/Toxiproxy.Net), a .NET client for Toxiproxy's HTTP API. While the test suite includes examples for latency and timeout toxics, the implementation supports **all toxics and features** that Toxiproxy itself supports. + +## Installation + +Add the following dependency to your project file: + +```shell +dotnet add package Testcontainers.Toxiproxy +``` + +## Usage Example + +You can start a Toxiproxy container instance and configure proxies/toxics from any .NET test or application. + +```csharp +var proxyPort = 12345; +var serverPort = 12346; + +var container = new ToxiproxyBuilder() + .WithProxy("my-proxy", $"0.0.0.0:{proxyPort}", $"host.docker.internal:{serverPort}") + .WithPortBinding(proxyPort, false) + .Build(); + +await container.StartAsync(); + +var proxy = container.Client.FindProxy("my-proxy"); + +proxy.Add(new LatencyToxic +{ + Name = "latency-toxic", + Stream = ToxicDirection.DownStream, + Attributes = { Latency = 500 } +}); + +// You can use the proxy (127.0.0.1:proxyPort) to connect with the injected network condition. +``` + +## Available Features + +- Add and remove proxies dynamically +- Inject latency, timeout, bandwidth limit, and more via toxics +- Use `Toxiproxy.Net` to interact with the running Toxiproxy server +- Test fault tolerance of networked services in isolated environments + +> Note: The library leverages the official [Toxiproxy.Net](https://github.com/mdevilliers/Toxiproxy.Net) client. Though the test suite demonstrates a couple of toxic types (e.g., latency, timeout), the module supports **all Toxiproxy features**. + +## Running Tests + +To execute the tests, use the command: + +```shell +dotnet test +``` diff --git a/src/Testcontainers.Toxiproxy/.editorconfig b/src/Testcontainers.Toxiproxy/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Toxiproxy/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Toxiproxy/Testcontainers.Toxiproxy.csproj b/src/Testcontainers.Toxiproxy/Testcontainers.Toxiproxy.csproj new file mode 100644 index 000000000..3530cccd9 --- /dev/null +++ b/src/Testcontainers.Toxiproxy/Testcontainers.Toxiproxy.csproj @@ -0,0 +1,13 @@ + + + net8.0;net9.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Toxiproxy/ToxiproxyBuilder.cs b/src/Testcontainers.Toxiproxy/ToxiproxyBuilder.cs new file mode 100644 index 000000000..0080bf82b --- /dev/null +++ b/src/Testcontainers.Toxiproxy/ToxiproxyBuilder.cs @@ -0,0 +1,112 @@ +using Toxiproxy.Net; + +namespace Testcontainers.Toxiproxy; + +/// +[PublicAPI] +public sealed class ToxiproxyBuilder : ContainerBuilder +{ + public const string ToxiproxyImage = "ghcr.io/shopify/toxiproxy"; + public const ushort ControlPort = 8474; + + private readonly List _initialProxies = new(); + + /// + /// Initializes a new instance of the class. + /// + private ToxiproxyBuilder(ToxiproxyConfiguration resourceConfiguration, List initialProxies) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + _initialProxies = initialProxies; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public ToxiproxyBuilder() + : this(new ToxiproxyConfiguration(), new List()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + private ToxiproxyBuilder(ToxiproxyConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override ToxiproxyConfiguration DockerResourceConfiguration { get; } + + /// + public override ToxiproxyContainer Build() + { + Validate(); + return new ToxiproxyContainer(DockerResourceConfiguration, _initialProxies); + } + + /// + /// Initialize the default Toxiproxy configuration with image, port, and wait strategy. + /// + /// A configured instance of . + protected override ToxiproxyBuilder Init() + { + // Define a wait strategy that waits for the Toxiproxy HTTP API to respond with 200 OK at /proxies. + return base.Init() + .WithImage(ToxiproxyImage) // Set the Toxiproxy image. + .WithPortBinding(ControlPort, true) // Bind the control port. + .WithWaitStrategy(Wait.ForUnixContainer() // Use HTTP-based wait strategy. + .UntilHttpRequestIsSucceeded(request => request + .ForPort(ControlPort) + .ForPath("/proxies") + .ForStatusCode(System.Net.HttpStatusCode.OK))); + } + + /// + /// Adds an initial proxy that will be created automatically after the container starts. + /// + /// The proxy name. + /// The listen address (e.g., 127.0.0.1:8888). + /// The upstream address (e.g., backend:80). + /// The builder instance. + public ToxiproxyBuilder WithProxy(string name, string listen, string upstream) + { + _initialProxies.Add(new Proxy + { + Name = name, + Enabled = true, + Listen = listen, + Upstream = upstream + }); + + return this; + } + + /// + protected override void Validate() + { + base.Validate(); + _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration)).NotNull(); + } + + /// + protected override ToxiproxyBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new ToxiproxyConfiguration(resourceConfiguration)); + } + + /// + protected override ToxiproxyBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new ToxiproxyConfiguration(resourceConfiguration)); + } + + /// + protected override ToxiproxyBuilder Merge(ToxiproxyConfiguration oldValue, ToxiproxyConfiguration newValue) + { + var mergedConfiguration = new ToxiproxyConfiguration(oldValue, newValue); + return new ToxiproxyBuilder(mergedConfiguration, new List(_initialProxies)); + } +} diff --git a/src/Testcontainers.Toxiproxy/ToxiproxyConfiguration.cs b/src/Testcontainers.Toxiproxy/ToxiproxyConfiguration.cs new file mode 100644 index 000000000..c4a3efdca --- /dev/null +++ b/src/Testcontainers.Toxiproxy/ToxiproxyConfiguration.cs @@ -0,0 +1,25 @@ +namespace Testcontainers.Toxiproxy; + +/// +[PublicAPI] +public sealed class ToxiproxyConfiguration : ContainerConfiguration +{ + public ToxiproxyConfiguration() + { + } + + public ToxiproxyConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + public ToxiproxyConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + public ToxiproxyConfiguration(ToxiproxyConfiguration oldValue, ToxiproxyConfiguration newValue) + : base(oldValue, newValue) + { + } +} \ No newline at end of file diff --git a/src/Testcontainers.Toxiproxy/ToxiproxyContainer.cs b/src/Testcontainers.Toxiproxy/ToxiproxyContainer.cs new file mode 100644 index 000000000..a51a789ab --- /dev/null +++ b/src/Testcontainers.Toxiproxy/ToxiproxyContainer.cs @@ -0,0 +1,62 @@ +using System.Threading; +using System.Threading.Tasks; +using Toxiproxy.Net; +using ToxiproxyNetClient = Toxiproxy.Net.Client; + +namespace Testcontainers.Toxiproxy; + +/// +[PublicAPI] +public sealed class ToxiproxyContainer : DockerContainer +{ + private readonly ToxiproxyConfiguration _configuration; + private readonly IEnumerable _initialProxies; + private ToxiproxyNetClient? _client; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// Optional proxies to be created automatically after startup. + public ToxiproxyContainer(ToxiproxyConfiguration configuration, IEnumerable? initialProxies = null) + : base(configuration) + { + _configuration = configuration; + _initialProxies = initialProxies ?? Enumerable.Empty(); + } + + /// + /// Gets the Toxiproxy client. Must call before accessing. + /// + public ToxiproxyNetClient Client => + _client ?? throw new InvalidOperationException("Toxiproxy client is not initialized. Call StartAsync() first."); + + /// + /// Gets the full URI of the Toxiproxy control endpoint. + /// + public Uri GetControlUri() + { + return new Uri($"http://{Hostname}:{GetMappedPublicPort(ToxiproxyBuilder.ControlPort)}"); + } + + /// + public override async Task StartAsync(CancellationToken ct = default) + { + await base.StartAsync(ct); + + try + { + var connection = new Connection(Hostname, GetMappedPublicPort(ToxiproxyBuilder.ControlPort)); + _client = connection.Client(); + + foreach (var proxy in _initialProxies) + { + _client.Add(proxy); + } + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to initialize Toxiproxy client or create initial proxies.", ex); + } + } +} \ No newline at end of file diff --git a/src/Testcontainers.Toxiproxy/Usings.cs b/src/Testcontainers.Toxiproxy/Usings.cs new file mode 100644 index 000000000..f889bad0a --- /dev/null +++ b/src/Testcontainers.Toxiproxy/Usings.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +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 Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/tests/Testcontainers.Toxiproxy.Tests/Testcontainers.Toxiproxy.Tests.csproj b/tests/Testcontainers.Toxiproxy.Tests/Testcontainers.Toxiproxy.Tests.csproj new file mode 100644 index 000000000..0e7c12f21 --- /dev/null +++ b/tests/Testcontainers.Toxiproxy.Tests/Testcontainers.Toxiproxy.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/tests/Testcontainers.Toxiproxy.Tests/ToxiproxyContainerTest.cs b/tests/Testcontainers.Toxiproxy.Tests/ToxiproxyContainerTest.cs new file mode 100644 index 000000000..13893a7ea --- /dev/null +++ b/tests/Testcontainers.Toxiproxy.Tests/ToxiproxyContainerTest.cs @@ -0,0 +1,446 @@ +namespace Testcontainers.Toxiproxy; + +/// +/// Integration tests for the Toxiproxy container module. +/// +public sealed class ToxiproxyContainerTest : IAsyncLifetime +{ + private readonly ToxiproxyContainer _toxiproxyContainer = new ToxiproxyBuilder().Build(); + + /// + public Task InitializeAsync() + { + return _toxiproxyContainer.StartAsync(); + } + + /// + public Task DisposeAsync() + { + return _toxiproxyContainer.DisposeAsync().AsTask(); + } + + [Fact] + public void CanCreateAndFindProxy() + { + // Arrange + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "localToGoogle", + Enabled = true, + Listen = "127.0.0.1:44399", + Upstream = "google.com:443" + }; + + // Act + client.Add(proxy); + + // Assert + var retrievedProxy = client.FindProxy(proxy.Name); + Assert.NotNull(retrievedProxy); + Assert.Equal("localToGoogle", retrievedProxy.Name); + Assert.Equal("127.0.0.1:44399", retrievedProxy.Listen); + Assert.Equal("google.com:443", retrievedProxy.Upstream); + } + + [Fact] + public void CanFindAllProxies() + { + // Arrange + var client = _toxiproxyContainer.Client; + + var proxyOne = new Proxy + { + Name = "proxyOne", + Enabled = true, + Listen = "127.0.0.1:44400", + Upstream = "example.com:80" + }; + + var proxyTwo = new Proxy + { + Name = "proxyTwo", + Enabled = true, + Listen = "127.0.0.1:44401", + Upstream = "test.com:80" + }; + + client.Add(proxyOne); + client.Add(proxyTwo); + + // Act + var allProxies = client.All(); + + // Assert + Assert.Equal(2, allProxies.Keys.Count); + Assert.True(allProxies.ContainsKey("proxyOne")); + Assert.True(allProxies.ContainsKey("proxyTwo")); + } + + [Fact] + public void CanDeleteProxy() + { + // Arrange + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "proxyToDelete", + Enabled = true, + Listen = "127.0.0.1:44402", + Upstream = "delete.com:80" + }; + + var addedProxy = client.Add(proxy); + + // Act + addedProxy.Delete(); + + // Assert + Assert.Throws(() => client.FindProxy("proxyToDelete")); + } + + [Fact] + public void CanAddSlowCloseToxic() + { + // Arrange + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "proxyWithToxic", + Enabled = true, + Listen = "127.0.0.1:44403", + Upstream = "toxic.com:80" + }; + + var addedProxy = client.Add(proxy); + + // Add a SlowCloseToxic to the proxy + var slowCloseToxic = new SlowCloseToxic + { + Name = "slowCloseToxic", + Stream = ToxicDirection.DownStream, + Toxicity = 0.8 + }; + slowCloseToxic.Attributes.Delay = 50; + + addedProxy.Add(slowCloseToxic); + addedProxy.Update(); + + // Act + var toxics = addedProxy.GetAllToxics().ToList(); + + // Assert + Assert.Single(toxics); + var retrievedToxic = toxics.First() as SlowCloseToxic; + Assert.NotNull(retrievedToxic); + Assert.Equal("slowCloseToxic", retrievedToxic.Name); + Assert.Equal(50, retrievedToxic.Attributes.Delay); + Assert.Equal(ToxicDirection.DownStream, retrievedToxic.Stream); + } + + [Fact] + public void CreatingDuplicateProxyThrows() + { + // Arrange + var client = _toxiproxyContainer.Client; + var proxy = new Proxy + { + Name = "duplicate", + Enabled = true, + Listen = "127.0.0.1:44500", + Upstream = "service:80" + }; + client.Add(proxy); + + // Act & Assert + Assert.Throws(() => client.Add(proxy)); + } + + [Fact] + public void InvalidListenAddressThrows() + { + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "invalidListen", + Enabled = true, + Listen = "notaport", + Upstream = "localhost:1234" + }; + + Assert.Throws(() => client.Add(proxy)); + } + + [Fact] + public void CanDisableProxy() + { + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "disabledProxy", + Enabled = true, + Listen = "127.0.0.1:44501", + Upstream = "service.com:80" + }; + + var added = client.Add(proxy); + added.Enabled = false; + added.Update(); + + var updated = client.FindProxy("disabledProxy"); + Assert.False(updated.Enabled); + } + + [Fact] + public void CanRemoveAllToxics() + { + var client = _toxiproxyContainer.Client; + + var proxy = new Proxy + { + Name = "proxyWithToxics", + Enabled = true, + Listen = "127.0.0.1:44503", + Upstream = "api:80" + }; + + var added = client.Add(proxy); + + var toxic = new SlowCloseToxic + { + Name = "slow", + Stream = ToxicDirection.DownStream, + Toxicity = 1.0, + Attributes = { Delay = 100 } + }; + + added.Add(toxic); + added.RemoveToxic("slow"); + + var toxics = added.GetAllToxics(); + Assert.Empty(toxics); + } + + [Fact] + public async Task LatencyToxicConfigurationIsApplied() + { + var container = new ToxiproxyBuilder() + .WithProxy("latency", "0.0.0.0:12345", "localhost:12346") + .WithPortBinding(12345, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(req => req + .ForPort(8474) + .ForPath("/proxies"))) + .Build(); + + await container.StartAsync(); + await WaitForProxyToBeReady(container.Client, "latency", TimeSpan.FromSeconds(10)); + + var proxy = container.Client.FindProxy("latency"); + + proxy.Add(new LatencyToxic + { + Name = "latency-toxic", + Stream = ToxicDirection.DownStream, + Attributes = { Latency = 500 } + }); + + await Task.Delay(250); + + var updated = container.Client.FindProxy("latency"); + var toxics = updated.GetAllToxics(); + var toxic = toxics.FirstOrDefault(t => t.Name == "latency-toxic") as LatencyToxic; + + Assert.NotNull(toxic); + Assert.Equal(500, toxic.Attributes.Latency); + } + + [Fact] + public async Task LatencyToxic_ShouldIntroduceExpectedDelay() + { + var serverPort = GetFreePort(); + var listener = new TcpListener(IPAddress.Loopback, serverPort); + listener.Start(); + + _ = Task.Run(async () => + { + using var serverClient = await listener.AcceptTcpClientAsync(); + using var stream = serverClient.GetStream(); + var buffer = new byte[1024]; + int bytesRead = await stream.ReadAsync(buffer); + await stream.WriteAsync(buffer, 0, bytesRead); + }); + + var proxyPort = GetFreePort(); + var proxyName = "latency-proxy"; + var container = new ToxiproxyBuilder() + .WithProxy(proxyName, $"0.0.0.0:{proxyPort}", $"host.docker.internal:{serverPort}") + .WithPortBinding(proxyPort, false) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(req => req + .ForPort(8474) + .ForPath("/proxies") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); + + await container.StartAsync(); + + var proxy = container.Client.FindProxy(proxyName); + proxy.Add(new LatencyToxic + { + Name = "latency", + Stream = ToxicDirection.DownStream, + Toxicity = 1.0, + Attributes = { Latency = 500 } + }); + + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", proxyPort); + using var stream = client.GetStream(); + + var message = Encoding.UTF8.GetBytes("test"); + var buffer = new byte[message.Length]; + + var sw = Stopwatch.StartNew(); + await stream.WriteAsync(message); + await stream.ReadAsync(buffer); + sw.Stop(); + + var delay = sw.ElapsedMilliseconds; + + await container.DisposeAsync(); + listener.Stop(); + + const int expectedMin = 450; + const int expectedMax = 2000; + Assert.True(delay >= expectedMin && delay <= expectedMax, + $"Expected delay between {expectedMin}ms and {expectedMax}ms, but got {delay}ms"); + } + + + private static async Task WaitForProxyToBeActive(Client client, string proxyName, string host, int port, TimeSpan timeout) + { + var start = DateTime.UtcNow; + Exception? lastException = null; + + while (DateTime.UtcNow - start < timeout) + { + try + { + var proxy = client.FindProxy(proxyName); + if (proxy.Enabled) + { + using var tcp = new TcpClient(); + await tcp.ConnectAsync(host, port); + return; + } + } + catch (Exception ex) + { + lastException = ex; + await Task.Delay(100); + } + } + + throw new TimeoutException($"Proxy '{proxyName}' on {host}:{port} did not become ready in time.", lastException); + } + + [Fact] + public async Task TimeoutToxic_ShouldDropConnectionAfterTimeout() + { + var serverPort = GetFreePort(); + var listener = new TcpListener(IPAddress.Loopback, serverPort); + listener.Start(); + + _ = Task.Run(async () => + { + using var serverClient = await listener.AcceptTcpClientAsync(); + using var stream = serverClient.GetStream(); + var buffer = new byte[1024]; + await stream.ReadAsync(buffer); + }); + + var proxyPort = GetFreePort(); + var proxyName = "timeout-proxy"; + + var container = new ToxiproxyBuilder() + .WithProxy(proxyName, $"0.0.0.0:{proxyPort}", $"host.docker.internal:{serverPort}") + .WithPortBinding(proxyPort, false) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(req => req + .ForPort(8474) + .ForPath("/proxies") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); + + await container.StartAsync(); + var proxy = container.Client.FindProxy(proxyName); + + proxy.Add(new TimeoutToxic + { + Name = "timeout-toxic", + Stream = ToxicDirection.UpStream, + Toxicity = 1.0, + Attributes = { Timeout = 1000 } + }); + + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", proxyPort); + + using var stream = client.GetStream(); + var payload = Encoding.UTF8.GetBytes("test"); + + var sw = Stopwatch.StartNew(); + Exception? ex = await Record.ExceptionAsync(async () => + { + await stream.WriteAsync(payload); + await stream.ReadAsync(new byte[5]); + }); + sw.Stop(); + + await container.DisposeAsync(); + listener.Stop(); + Assert.True(sw.ElapsedMilliseconds >= 1000, $"Expected timeout after >= 1000ms but took {sw.ElapsedMilliseconds}ms."); + } + + + private static async Task WaitForProxyToBeReady(Client client, string proxyName, TimeSpan timeout) + { + var start = DateTime.UtcNow; + Exception? lastError = null; + + while (DateTime.UtcNow - start < timeout) + { + try + { + var proxy = client.FindProxy(proxyName); + if (!string.IsNullOrEmpty(proxy.Listen)) + { + return; + } + } + catch (Exception ex) + { + lastError = ex; + } + + await Task.Delay(100); + } + + throw new TimeoutException($"Proxy '{proxyName}' did not become ready in time.", lastError); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/tests/Testcontainers.Toxiproxy.Tests/Usings.cs b/tests/Testcontainers.Toxiproxy.Tests/Usings.cs new file mode 100644 index 000000000..8a2461034 --- /dev/null +++ b/tests/Testcontainers.Toxiproxy.Tests/Usings.cs @@ -0,0 +1,9 @@ +// Global using directives + +global using System.Diagnostics; +global using System.Net; +global using System.Net.Sockets; +global using System.Text; +global using DotNet.Testcontainers.Builders; +global using Toxiproxy.Net; +global using Toxiproxy.Net.Toxics; \ No newline at end of file