From db8544d43a28903a79784475406769977c3a4ec7 Mon Sep 17 00:00:00 2001
From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 12:22:27 +0100
Subject: [PATCH 1/3] feat(LocalStack): Require auth token for 4.15 and onwards
---
.../LocalStackBuilder.cs | 14 +++++++
src/Testcontainers.LocalStack/Usings.cs | 1 +
.../Dockerfile | 2 +-
.../LocalStackBuilderTest.cs | 38 +++++++++++++++++++
4 files changed, 54 insertions(+), 1 deletion(-)
create mode 100644 tests/Testcontainers.LocalStack.Tests/LocalStackBuilderTest.cs
diff --git a/src/Testcontainers.LocalStack/LocalStackBuilder.cs b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
index aa62d97c9..b1b6fba1e 100644
--- a/src/Testcontainers.LocalStack/LocalStackBuilder.cs
+++ b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
@@ -80,6 +80,20 @@ protected override LocalStackBuilder Init()
request.ForPath("/_localstack/health").ForPort(LocalStackPort)));
}
+ ///
+ protected override void Validate()
+ {
+ const string message = "The image '{0}' requires the LOCALSTACK_AUTH_TOKEN environment variable for LocalStack 4.15 and onwards. Set LOCALSTACK_AUTH_TOKEN before starting the container. For more information, see https://blog.localstack.cloud/localstack-single-image-next-steps/.";
+
+ base.Validate();
+
+ Predicate requiresAuthToken = value => !value.Environments.TryGetValue("LOCALSTACK_AUTH_TOKEN", out _)
+ && (value.Image.MatchLatestOrNightly() || value.Image.MatchVersion(v => v.Major > 4 || v.Major == 4 && v.Minor > 14));
+
+ _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
+ .ThrowIf(argument => requiresAuthToken(argument.Value), argument => new ArgumentException(string.Format(message, argument.Value.Image.FullName), argument.Name));
+ }
+
///
protected override LocalStackBuilder Clone(IResourceConfiguration resourceConfiguration)
{
diff --git a/src/Testcontainers.LocalStack/Usings.cs b/src/Testcontainers.LocalStack/Usings.cs
index 9bd35674e..cf5dd0969 100644
--- a/src/Testcontainers.LocalStack/Usings.cs
+++ b/src/Testcontainers.LocalStack/Usings.cs
@@ -1,6 +1,7 @@
global using System;
global using System.Diagnostics.CodeAnalysis;
global using Docker.DotNet.Models;
+global using DotNet.Testcontainers;
global using DotNet.Testcontainers.Builders;
global using DotNet.Testcontainers.Configurations;
global using DotNet.Testcontainers.Containers;
diff --git a/tests/Testcontainers.LocalStack.Tests/Dockerfile b/tests/Testcontainers.LocalStack.Tests/Dockerfile
index 7fe0bd9e8..ffb418cc2 100644
--- a/tests/Testcontainers.LocalStack.Tests/Dockerfile
+++ b/tests/Testcontainers.LocalStack.Tests/Dockerfile
@@ -1,2 +1,2 @@
-FROM localstack/localstack:2.0.2@sha256:2b4ec60261238bfc9573193380724ed4b9ee9fa0f39726275cd8feb48a254219
+FROM localstack/localstack:4.14.0@sha256:3ebc37595918b8accb852f8048fef2aff047d465167edd655528065b07bc364a
FROM localstack/localstack:1.4.0@sha256:7badf31c550f81151c485980e17542592942d7f05acc09723c5f276d41b5927d AS v1_4_0
\ No newline at end of file
diff --git a/tests/Testcontainers.LocalStack.Tests/LocalStackBuilderTest.cs b/tests/Testcontainers.LocalStack.Tests/LocalStackBuilderTest.cs
new file mode 100644
index 000000000..687c1a65d
--- /dev/null
+++ b/tests/Testcontainers.LocalStack.Tests/LocalStackBuilderTest.cs
@@ -0,0 +1,38 @@
+namespace Testcontainers.LocalStack;
+
+public sealed class LocalStackBuilderTest
+{
+ [Fact]
+ public void LocalStack415WithoutAuthTokenThrowsArgumentException()
+ {
+ const string message = "The image 'localstack/localstack:4.15.0' requires the LOCALSTACK_AUTH_TOKEN environment variable for LocalStack 4.15 and onwards.";
+ ExpectArgEx(message, () => new LocalStackBuilder("localstack/localstack:4.15.0").Build());
+ }
+
+ [Fact]
+ public void LocalStack500WithoutAuthTokenThrowsArgumentException()
+ {
+ const string message = "The image 'localstack/localstack:5.0.0' requires the LOCALSTACK_AUTH_TOKEN environment variable for LocalStack 4.15 and onwards.";
+ ExpectArgEx(message, () => new LocalStackBuilder("localstack/localstack:5.0.0").Build());
+ }
+
+ [Fact]
+ public void LocalStack414WithoutAuthTokenDoesNotThrowArgumentException()
+ {
+ var exception = Xunit.Record.Exception(() => new LocalStackBuilder("localstack/localstack:4.14.0").Build());
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void LocalStack415WithAuthTokenDoesNotThrowArgumentException()
+ {
+ var exception = Xunit.Record.Exception(() => new LocalStackBuilder("localstack/localstack:4.15.0").WithEnvironment("LOCALSTACK_AUTH_TOKEN", "").Build());
+ Assert.Null(exception);
+ }
+
+ private static void ExpectArgEx(string expectedStartString, Action testCode)
+ {
+ var exception = Assert.Throws(testCode);
+ Assert.StartsWith(expectedStartString, exception.Message);
+ }
+}
\ No newline at end of file
From d2abdc1664cf4cb04222a3add8db20aa1074455c Mon Sep 17 00:00:00 2001
From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 15:35:06 +0100
Subject: [PATCH 2/3] chore: Align guard usage
---
src/Testcontainers.Kafka/KafkaBuilder.cs | 3 ++-
src/Testcontainers.LocalStack/LocalStackBuilder.cs | 4 ++--
src/Testcontainers.Neo4j/Neo4jBuilder.cs | 6 +++---
src/Testcontainers.Oracle/OracleBuilder.cs | 6 +++---
src/Testcontainers/Builders/AbstractBuilder`4.cs | 6 +++---
src/Testcontainers/Builders/ContainerBuilder`3.cs | 6 +++---
src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs | 2 +-
src/Testcontainers/Builders/NetworkBuilder.cs | 2 +-
src/Testcontainers/Builders/VolumeBuilder.cs | 2 +-
9 files changed, 19 insertions(+), 18 deletions(-)
diff --git a/src/Testcontainers.Kafka/KafkaBuilder.cs b/src/Testcontainers.Kafka/KafkaBuilder.cs
index 99e6cbf9e..e9ec0631d 100644
--- a/src/Testcontainers.Kafka/KafkaBuilder.cs
+++ b/src/Testcontainers.Kafka/KafkaBuilder.cs
@@ -275,7 +275,8 @@ protected override void Validate()
base.Validate();
- Predicate vendorNotFound = value => value == null && !VendorConfigurations.Any(v => v.IsImageFromVendor(DockerResourceConfiguration.Image));
+ Predicate vendorNotFound = value =>
+ value == null && !VendorConfigurations.Any(v => v.IsImageFromVendor(DockerResourceConfiguration.Image));
_ = Guard.Argument(DockerResourceConfiguration.Vendor, nameof(DockerResourceConfiguration.Vendor))
.ThrowIf(argument => vendorNotFound(argument.Value), argument => new ArgumentException(message, argument.Name));
diff --git a/src/Testcontainers.LocalStack/LocalStackBuilder.cs b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
index b1b6fba1e..7814ed843 100644
--- a/src/Testcontainers.LocalStack/LocalStackBuilder.cs
+++ b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
@@ -87,8 +87,8 @@ protected override void Validate()
base.Validate();
- Predicate requiresAuthToken = value => !value.Environments.TryGetValue("LOCALSTACK_AUTH_TOKEN", out _)
- && (value.Image.MatchLatestOrNightly() || value.Image.MatchVersion(v => v.Major > 4 || v.Major == 4 && v.Minor > 14));
+ Predicate requiresAuthToken = value =>
+ !value.Environments.TryGetValue("LOCALSTACK_AUTH_TOKEN", out _) && (value.Image.MatchLatestOrNightly() || value.Image.MatchVersion(v => v.Major > 4 || v.Major == 4 && v.Minor > 14));
_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
.ThrowIf(argument => requiresAuthToken(argument.Value), argument => new ArgumentException(string.Format(message, argument.Value.Image.FullName), argument.Name));
diff --git a/src/Testcontainers.Neo4j/Neo4jBuilder.cs b/src/Testcontainers.Neo4j/Neo4jBuilder.cs
index 59944600b..d926bde43 100644
--- a/src/Testcontainers.Neo4j/Neo4jBuilder.cs
+++ b/src/Testcontainers.Neo4j/Neo4jBuilder.cs
@@ -145,11 +145,11 @@ protected override void Validate()
base.Validate();
- Predicate licenseAgreementNotAccepted = value => value.Image.Tag != null && value.Image.Tag.Contains("enterprise")
- && (!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal));
+ Predicate licenseAgreementNotAccepted = value =>
+ value.Image.Tag != null && value.Image.Tag.Contains("enterprise") && (!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal));
_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
- .ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
+ .ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, argument.Value.Image.FullName), argument.Name));
}
///
diff --git a/src/Testcontainers.Oracle/OracleBuilder.cs b/src/Testcontainers.Oracle/OracleBuilder.cs
index c92bfb6a4..6005cf4ad 100644
--- a/src/Testcontainers.Oracle/OracleBuilder.cs
+++ b/src/Testcontainers.Oracle/OracleBuilder.cs
@@ -139,15 +139,15 @@ protected override OracleBuilder Init()
///
protected override void Validate()
{
- base.Validate();
-
const string message = "The image '{0}' does not support configuring the database. It is only supported on Oracle 18 and onwards.";
+ base.Validate();
+
Predicate databaseConfigurationNotSupported = value =>
value.Database != null && value.Image.MatchVersion(v => v.Major < 18);
_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Database))
- .ThrowIf(argument => databaseConfigurationNotSupported(argument.Value), _ => new NotSupportedException(string.Format(message, DockerResourceConfiguration.Image.FullName)));
+ .ThrowIf(argument => databaseConfigurationNotSupported(argument.Value), argument => new NotSupportedException(string.Format(message, argument.Value.Image.FullName)));
_ = Guard.Argument(DockerResourceConfiguration.Username, nameof(DockerResourceConfiguration.Username))
.NotNull()
diff --git a/src/Testcontainers/Builders/AbstractBuilder`4.cs b/src/Testcontainers/Builders/AbstractBuilder`4.cs
index 5b7c0d857..8a826b3da 100644
--- a/src/Testcontainers/Builders/AbstractBuilder`4.cs
+++ b/src/Testcontainers/Builders/AbstractBuilder`4.cs
@@ -140,14 +140,14 @@ protected virtual TBuilderEntity Init()
/// Thrown when a mandatory Docker resource configuration is not set.
protected virtual void Validate()
{
- _ = Guard.Argument(DockerResourceConfiguration.Logger, nameof(IResourceConfiguration.Logger))
+ _ = Guard.Argument(DockerResourceConfiguration.Logger, nameof(DockerResourceConfiguration.Logger))
.NotNull();
- _ = Guard.Argument(DockerResourceConfiguration.DockerEndpointAuthConfig, nameof(IResourceConfiguration.DockerEndpointAuthConfig))
+ _ = Guard.Argument(DockerResourceConfiguration.DockerEndpointAuthConfig, nameof(DockerResourceConfiguration.DockerEndpointAuthConfig))
.ThrowIf(argument => argument.Value == null, CreateDockerUnavailableException);
const string reuseNotSupported = "Reuse cannot be used in conjunction with WithCleanUp(true).";
- _ = Guard.Argument(DockerResourceConfiguration, nameof(IResourceConfiguration.Reuse))
+ _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Reuse))
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && !Guid.Empty.Equals(argument.Value.SessionId), argument => new ArgumentException(reuseNotSupported, argument.Name));
}
diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs
index b5b8cb32a..ac05791e9 100644
--- a/src/Testcontainers/Builders/ContainerBuilder`3.cs
+++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs
@@ -497,10 +497,10 @@ protected override void Validate()
base.Validate();
const string reuseNotSupported = "Reuse cannot be used in conjunction with WithAutoRemove(true).";
- _ = Guard.Argument(DockerResourceConfiguration, nameof(IContainerConfiguration.Reuse))
+ _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Reuse))
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value && argument.Value.AutoRemove.HasValue && argument.Value.AutoRemove.Value, argument => new ArgumentException(reuseNotSupported, argument.Name));
- _ = Guard.Argument(DockerResourceConfiguration.Image, nameof(IContainerConfiguration.Image))
+ _ = Guard.Argument(DockerResourceConfiguration.Image, nameof(DockerResourceConfiguration.Image))
.NotNull();
}
@@ -516,7 +516,7 @@ protected virtual void ValidateLicenseAgreement()
!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal);
_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
- .ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
+ .ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, argument.Value.Image.FullName), argument.Name));
}
///
diff --git a/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs b/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs
index 2a4f881e6..cd233c70b 100644
--- a/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs
+++ b/src/Testcontainers/Builders/ImageFromDockerfileBuilder.cs
@@ -133,7 +133,7 @@ protected override void Validate()
base.Validate();
const string reuseNotSupported = "Building an image does not support the reuse feature. To keep the built image, disable the cleanup.";
- _ = Guard.Argument(DockerResourceConfiguration, nameof(IImageFromDockerfileConfiguration.Reuse))
+ _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Reuse))
.ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value, argument => new ArgumentException(reuseNotSupported, argument.Name));
}
diff --git a/src/Testcontainers/Builders/NetworkBuilder.cs b/src/Testcontainers/Builders/NetworkBuilder.cs
index 43b7b957c..61a46ae11 100644
--- a/src/Testcontainers/Builders/NetworkBuilder.cs
+++ b/src/Testcontainers/Builders/NetworkBuilder.cs
@@ -83,7 +83,7 @@ protected override void Validate()
{
base.Validate();
- _ = Guard.Argument(DockerResourceConfiguration.Name, nameof(INetworkConfiguration.Name))
+ _ = Guard.Argument(DockerResourceConfiguration.Name, nameof(DockerResourceConfiguration.Name))
.NotNull()
.NotEmpty();
}
diff --git a/src/Testcontainers/Builders/VolumeBuilder.cs b/src/Testcontainers/Builders/VolumeBuilder.cs
index a56d246d1..88984eb66 100644
--- a/src/Testcontainers/Builders/VolumeBuilder.cs
+++ b/src/Testcontainers/Builders/VolumeBuilder.cs
@@ -68,7 +68,7 @@ protected override void Validate()
{
base.Validate();
- _ = Guard.Argument(DockerResourceConfiguration.Name, nameof(IVolumeConfiguration.Name))
+ _ = Guard.Argument(DockerResourceConfiguration.Name, nameof(DockerResourceConfiguration.Name))
.NotNull()
.NotEmpty();
}
From 6815de9a59301162d06ff5989b6ef35956469ec0 Mon Sep 17 00:00:00 2001
From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com>
Date: Thu, 19 Mar 2026 16:03:43 +0100
Subject: [PATCH 3/3] chore: Simplify error message
---
src/Testcontainers.LocalStack/LocalStackBuilder.cs | 2 +-
.../DockerEndpointAuthenticationProviderTest.cs | 8 +++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/Testcontainers.LocalStack/LocalStackBuilder.cs b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
index 7814ed843..4f278d812 100644
--- a/src/Testcontainers.LocalStack/LocalStackBuilder.cs
+++ b/src/Testcontainers.LocalStack/LocalStackBuilder.cs
@@ -83,7 +83,7 @@ protected override LocalStackBuilder Init()
///
protected override void Validate()
{
- const string message = "The image '{0}' requires the LOCALSTACK_AUTH_TOKEN environment variable for LocalStack 4.15 and onwards. Set LOCALSTACK_AUTH_TOKEN before starting the container. For more information, see https://blog.localstack.cloud/localstack-single-image-next-steps/.";
+ const string message = "The image '{0}' requires the LOCALSTACK_AUTH_TOKEN environment variable for LocalStack 4.15 and onwards. For more information, see https://blog.localstack.cloud/localstack-single-image-next-steps/.";
base.Validate();
diff --git a/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs b/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs
index 972a20bf0..913ec1dd9 100644
--- a/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs
+++ b/tests/Testcontainers.Tests/Unit/Configurations/DockerEndpointAuthenticationProviderTest.cs
@@ -39,9 +39,11 @@ internal void AuthProviderShouldBeApplicable(IDockerEndpointAuthenticationProvid
[ClassData(typeof(AuthConfigTestData))]
internal void AuthConfigShouldGetDockerClientEndpoint(IDockerEndpointAuthenticationConfiguration authConfig, Uri dockerClientEndpoint)
{
- var dockerClient = authConfig.GetDockerClientBuilder().Build();
- Assert.Equal(dockerClientEndpoint, authConfig.Endpoint);
- Assert.Equal(dockerClientEndpoint, dockerClient.Options.Endpoint);
+ using (var dockerClient = authConfig.GetDockerClientBuilder().Build())
+ {
+ Assert.Equal(dockerClientEndpoint, authConfig.Endpoint);
+ Assert.Equal(dockerClientEndpoint, dockerClient.Options.Endpoint);
+ }
}
public sealed class TestcontainersHostEndpointAuthenticationProviderTest