From adf1b7ee36d135fb3972b89c2cc9bde4fd800313 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 8 Oct 2026 15:32:19 +0100 Subject: [PATCH 1/2] update to integration test securty service setup --- .../IntegrationTestCertificate.cs | 133 ++++++++++++++++++ .../TestContainers/BaseDockerHelper.cs | 78 ++++++---- .../TestContainers/DockerHelper.cs | 119 +++++++++------- 3 files changed, 253 insertions(+), 77 deletions(-) create mode 100644 Shared.IntegrationTesting/IntegrationTestCertificate.cs diff --git a/Shared.IntegrationTesting/IntegrationTestCertificate.cs b/Shared.IntegrationTesting/IntegrationTestCertificate.cs new file mode 100644 index 0000000..d6d00fc --- /dev/null +++ b/Shared.IntegrationTesting/IntegrationTestCertificate.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Shared.IntegrationTesting +{ + public sealed class IntegrationTestCertificate : IDisposable + { + private const string PasswordValue = "password"; + private const string ServerCertificateFileName = "aspnetapp-web-api.pfx"; + private const string RootCertificateFileName = "aspnetapp-root-cert.cer"; + private const string ContainerCertificateDirectoryPath = "/app/test-certs"; + private const string RootSubjectName = "CN=SecurityServiceIntegrationTestRoot"; + private const string LocalhostName = "localhost"; + private const string LoopbackAddress = "127.0.0.1"; + + private readonly string _certificateDirectory; + private readonly X509Certificate2 _certificate; + private bool _trustedRootInstalled; + private bool _disposed; + + private IntegrationTestCertificate(string certificateDirectory, X509Certificate2 certificate) + { + _certificateDirectory = certificateDirectory; + _certificate = certificate; + } + + public string CertificateDirectory => _certificateDirectory; + + public string ContainerCertificateDirectory => ContainerCertificateDirectoryPath; + + public string Password => PasswordValue; + + public string RootCertificatePath => Path.Combine(_certificateDirectory, RootCertificateFileName); + + public string ServerCertificatePath => Path.Combine(_certificateDirectory, ServerCertificateFileName); + + public string Thumbprint => _certificate.Thumbprint ?? string.Empty; + + public static IntegrationTestCertificate Create() + { + string certificateDirectory = Path.Combine(Path.GetTempPath(), $"securityservice-cert-{Guid.NewGuid():N}"); + Directory.CreateDirectory(certificateDirectory); + + using RSA rsa = RSA.Create(2048); + CertificateRequest request = new CertificateRequest(RootSubjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + + OidCollection enhancedKeyUsages = new OidCollection(); + enhancedKeyUsages.Add(new Oid("1.3.6.1.5.5.7.3.1")); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsages, false)); + + SubjectAlternativeNameBuilder subjectAlternativeNames = new SubjectAlternativeNameBuilder(); + subjectAlternativeNames.AddDnsName(LocalhostName); + subjectAlternativeNames.AddIpAddress(IPAddress.Parse(LoopbackAddress)); + request.CertificateExtensions.Add(subjectAlternativeNames.Build()); + + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + byte[] pfxBytes = certificate.Export(X509ContentType.Pfx, PasswordValue); + byte[] certificateBytes = certificate.Export(X509ContentType.Cert); + + string serverCertificatePath = Path.Combine(certificateDirectory, ServerCertificateFileName); + string rootCertificatePath = Path.Combine(certificateDirectory, RootCertificateFileName); + File.WriteAllBytes(serverCertificatePath, pfxBytes); + File.WriteAllBytes(rootCertificatePath, certificateBytes); + + var result = new IntegrationTestCertificate(certificateDirectory, X509CertificateLoader.LoadPkcs12(pfxBytes, PasswordValue, X509KeyStorageFlags.EphemeralKeySet)); + result.InstallTrustedRoot(); + return result; + } + + public void InstallTrustedRoot() + { + ThrowIfDisposed(); + + using X509Store store = new X509Store(StoreName.TrustedPeople, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + store.Add(_certificate); + _trustedRootInstalled = true; + } + + public string GetContainerCertificatePath() + { + return $"{ContainerCertificateDirectoryPath}/{ServerCertificateFileName}"; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_trustedRootInstalled) + { + RemoveTrustedRoot(); + } + + if (Directory.Exists(_certificateDirectory)) + { + Directory.Delete(_certificateDirectory, recursive: true); + } + + _certificate.Dispose(); + _disposed = true; + } + + private void RemoveTrustedRoot() + { + using X509Store store = new X509Store(StoreName.TrustedPeople, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + foreach (X509Certificate2 certificate in store.Certificates.Find(X509FindType.FindByThumbprint, Thumbprint, validOnly: false)) + { + store.Remove(certificate); + } + + _trustedRootInstalled = false; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + } +} diff --git a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs index b665946..bf047cb 100644 --- a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs @@ -517,47 +517,73 @@ public virtual ContainerBuilder SetupMessagingServiceContainer(){ return messagingServiceContainer; } - public virtual ContainerBuilder SetupSecurityServiceContainer(){ - this.Trace("About to Start Security Container"); + protected IntegrationTestCertificate? IntegrationTestCertificate; - Dictionary environmentVariables = this.GetCommonEnvironmentVariables(); - environmentVariables.Add("ServiceOptions:PublicOrigin",$"https://{this.SecurityServiceContainerName}:{DockerPorts.SecurityServiceDockerPort}"); - environmentVariables.Add("ServiceOptions:IssuerUrl",$"https://{this.SecurityServiceContainerName}:{DockerPorts.SecurityServiceDockerPort}"); - environmentVariables.Add("ASPNETCORE_ENVIRONMENT","IntegrationTest"); - environmentVariables.Add("urls",$"https://*:{DockerPorts.SecurityServiceDockerPort}"); + public ContainerBuilder SetupSecurityServiceContainer() + { + this.Trace("About to Start Security Container"); - environmentVariables.Add("ServiceOptions:PasswordOptions:RequiredLength","6"); - environmentVariables.Add("ServiceOptions:PasswordOptions:RequireDigit","false"); - environmentVariables.Add("ServiceOptions:PasswordOptions:RequireUpperCase","false"); - environmentVariables.Add("ServiceOptions:UserOptions:RequireUniqueEmail","false"); - environmentVariables.Add("ServiceOptions:SignInOptions:RequireConfirmedEmail","false"); + if (this.IntegrationTestCertificate is null) + { + throw new InvalidOperationException("Integration test certificate was not initialised before container setup."); + } - environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext", this.SetConnectionString( $"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase)); + Dictionary environmentVariables = this.GetCommonEnvironmentVariables(); + environmentVariables.Add("ServiceOptions:PublicOrigin", $"https://{this.SecurityServiceContainerName}:{DockerPorts.SecurityServiceDockerPort}"); + environmentVariables.Add("ServiceOptions:IssuerUrl", $"https://{this.SecurityServiceContainerName}:{DockerPorts.SecurityServiceDockerPort}"); + environmentVariables.Add("ASPNETCORE_ENVIRONMENT", "IntegrationTest"); + environmentVariables.Add("urls", $"https://*:{DockerPorts.SecurityServiceDockerPort}"); + environmentVariables.Add("ServiceOptions:KestrelOptions:Path", this.IntegrationTestCertificate.GetContainerCertificatePath()); + environmentVariables.Add("ServiceOptions:KestrelOptions:Password", this.IntegrationTestCertificate.Password); + environmentVariables.Add("ServiceOptions:PasswordOptions:RequiredLength", "6"); + environmentVariables.Add("ServiceOptions:PasswordOptions:RequireDigit", "false"); + environmentVariables.Add("ServiceOptions:PasswordOptions:RequireUpperCase", "false"); + environmentVariables.Add("ServiceOptions:UserOptions:RequireUniqueEmail", "false"); + environmentVariables.Add("ServiceOptions:SignInOptions:RequireConfirmedEmail", "false"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:Enabled", "true"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientId", "management-bootstrap"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientSecret", "management-bootstrap-secret"); + environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:password:0", "merchantClient"); + environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:hybrid:0", "testclient2"); + environmentVariables.Add("ServiceOptions:OAuth:EnableLegacyGrantTypes", "true"); + environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext", this.SetConnectionString($"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:ConfigurationDbContext", this.SetConnectionString($"Configuration-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:AuthenticationDbContext", this.SetConnectionString($"Authentication-{this.TestId}", this.UseSecureSqlServerDatabase)); Dictionary additionalEnvironmentVariables = this.GetAdditionalVariables(ContainerType.SecurityService); - - foreach (KeyValuePair additionalEnvironmentVariable in additionalEnvironmentVariables) { - environmentVariables.Add(additionalEnvironmentVariable.Key, additionalEnvironmentVariable.Value); + if (additionalEnvironmentVariables != null) + { + foreach (KeyValuePair additionalEnvironmentVariable in additionalEnvironmentVariables) + { + environmentVariables.Add(additionalEnvironmentVariable.Key, additionalEnvironmentVariable.Value); + } } - (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.SecurityService).Data; + SimpleResults.Result<(String imageName, Boolean useLatest)> imageDetailsResult = this.GetImageDetails(ContainerType.SecurityService); + if (imageDetailsResult.IsFailed) + { + throw new Exception($"Image details not found for {ContainerType.SecurityService}"); + } - ContainerBuilder securityServiceContainer = new ContainerBuilder() - .WithName(this.SecurityServiceContainerName) // similar to WithName() - .WithImage(imageDetails.imageName) + ContainerBuilder securityServiceContainer = new ContainerBuilder(imageDetailsResult.Data.imageName) + .WithName(this.SecurityServiceContainerName) .WithEnvironment(environmentVariables) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder) - .WithPortBinding(DockerPorts.SecurityServiceDockerPort, true); - - // TODO: might need this but not sure yet - //Int32? hostPort = this.GetHostPort(ContainerType.SecurityService); - //securityServiceContainer = hostPort == null ? securityServiceContainer.ExposePort(DockerPorts.SecurityServiceDockerPort) : securityServiceContainer.ExposePort(hostPort.Value, DockerPorts.SecurityServiceDockerPort); + .WithBindMount(this.IntegrationTestCertificate.CertificateDirectory, this.IntegrationTestCertificate.ContainerCertificateDirectory, AccessMode.ReadOnly); + + Int32? hostPort = this.GetHostPort(ContainerType.SecurityService); + if (hostPort is null || hostPort <= 0) + { + securityServiceContainer = securityServiceContainer.WithPortBinding(DockerPorts.SecurityServiceDockerPort, true); + } + else + { + securityServiceContainer = securityServiceContainer.WithPortBinding(DockerPorts.SecurityServiceDockerPort, hostPort.Value); + } return securityServiceContainer; } + public virtual ContainerBuilder ConfigureSqlContainer() { this.Trace("About to start SQL Server Container"); diff --git a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs index 5725d6e..3951c78 100644 --- a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs @@ -43,68 +43,85 @@ protected virtual void SetHostTraceFolder(String scenarioName) { public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices){ - Result result = await BaseDockerHelper.GetDockerEnginePlatform(); - this.DockerPlatform =result.Data; - - this.RequiredDockerServices = dockerServices; - - this.IsSecureEventStore = Environment.GetEnvironmentVariable("IsSecureEventStore") switch{ - null => false, - { Length: 0 } => false, - "false" => false, - _ => true - }; - this.SetHostTraceFolder(scenarioName); - - this.TestId = Guid.NewGuid(); - this.Trace($"Test Id is {this.TestId}"); + this.IntegrationTestCertificate = IntegrationTestCertificate.Create(); + try + { + Result result = await BaseDockerHelper.GetDockerEnginePlatform(); + this.DockerPlatform = result.Data; + + this.RequiredDockerServices = dockerServices; + + this.IsSecureEventStore = Environment.GetEnvironmentVariable("IsSecureEventStore") switch + { + null => false, + { Length: 0 } => false, + "false" => false, + _ => true + }; + this.SetHostTraceFolder(scenarioName); - this.SetupContainerNames(); + this.TestId = Guid.NewGuid(); - this.ClientDetails = ("serviceClient", "Secret1"); + this.Trace($"Test Id is {this.TestId}"); - INetwork testNetwork = await this.SetupTestNetwork(); - this.TestNetworks.Add(testNetwork); + this.SetupContainerNames(); - List networks = [ - testNetwork, + this.ClientDetails = ("serviceClient", "Secret1"); + + INetwork testNetwork = await this.SetupTestNetwork(); + this.TestNetworks.Add(testNetwork); + + List networks = [ + testNetwork, ]; - async Task StartWithTrace(Func setupContainer, DockerServices service) - { - DateTimeOffset startedAt = DateTimeOffset.UtcNow; - this.Trace($"Starting container [{service}]"); - await StartContainer2(setupContainer, networks, service); - TimeSpan elapsed = DateTimeOffset.UtcNow - startedAt; - this.Trace($"Container [{service}] started in {elapsed.TotalSeconds:N1}s"); - } + async Task StartWithTrace(Func setupContainer, DockerServices service) + { + DateTimeOffset startedAt = DateTimeOffset.UtcNow; + this.Trace($"Starting container [{service}]"); + await StartContainer2(setupContainer, networks, service); + TimeSpan elapsed = DateTimeOffset.UtcNow - startedAt; + this.Trace($"Container [{service}] started in {elapsed.TotalSeconds:N1}s"); + } - Func> setupContainerForService = service => service switch - { - DockerServices.SqlServer => this.ConfigureSqlContainer, - DockerServices.EventStore => this.SetupEventStoreContainer, - DockerServices.MessagingService => this.SetupMessagingServiceContainer, - DockerServices.SecurityService => this.SetupSecurityServiceContainer, - DockerServices.CallbackHandler => this.SetupCallbackHandlerContainer, - DockerServices.TestHost => this.SetupTestHostContainer, - DockerServices.TransactionProcessor => this.SetupTransactionProcessorContainer, - DockerServices.FileProcessor => this.SetupFileProcessorContainer, - DockerServices.TransactionProcessorAcl => this.SetupTransactionProcessorAclContainer, - DockerServices.ConfigurationHost => this.SetupConfigHostContainer, - DockerServices.EstateManagementUI => this.SetupEstateManagementUiContainer, - DockerServices.EstateReporting => this.SetupEstateReportingContainer, - _ => throw new InvalidOperationException($"No startup plan exists for docker service [{service}]") - }; - - foreach (IReadOnlyList startupGroup in this.GetStartupGroups()) + Func> setupContainerForService = service => service switch + { + DockerServices.SqlServer => this.ConfigureSqlContainer, + DockerServices.EventStore => this.SetupEventStoreContainer, + DockerServices.MessagingService => this.SetupMessagingServiceContainer, + DockerServices.SecurityService => this.SetupSecurityServiceContainer, + DockerServices.CallbackHandler => this.SetupCallbackHandlerContainer, + DockerServices.TestHost => this.SetupTestHostContainer, + DockerServices.TransactionProcessor => this.SetupTransactionProcessorContainer, + DockerServices.FileProcessor => this.SetupFileProcessorContainer, + DockerServices.TransactionProcessorAcl => this.SetupTransactionProcessorAclContainer, + DockerServices.ConfigurationHost => this.SetupConfigHostContainer, + DockerServices.EstateManagementUI => this.SetupEstateManagementUiContainer, + DockerServices.EstateReporting => this.SetupEstateReportingContainer, + _ => throw new InvalidOperationException($"No startup plan exists for docker service [{service}]") + }; + + foreach (IReadOnlyList startupGroup in this.GetStartupGroups()) + { + await Task.WhenAll(startupGroup.Select(service => StartWithTrace(setupContainerForService(service), service))); + } + + await this.LoadEventStoreProjections(); + + await this.CreateSubscriptions(); + } + catch { - await Task.WhenAll(startupGroup.Select(service => StartWithTrace(setupContainerForService(service), service))); + this.DisposeIntegrationTestCertificate(); + throw; } + } - await this.LoadEventStoreProjections(); - - await this.CreateSubscriptions(); + public void DisposeIntegrationTestCertificate() + { + this.IntegrationTestCertificate?.Dispose(); + this.IntegrationTestCertificate = null; } public override async Task StopContainersForScenarioRun(DockerServices sharedDockerServices) { From 9e21a95da671d94d6aabcc384e59046d2010c86b Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 8 Oct 2026 15:57:31 +0100 Subject: [PATCH 2/2] Address latest PR review comments --- .../TestContainers/BaseDockerHelper.cs | 2 +- .../TestContainers/DockerHelper.cs | 60 +++++++++++-------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs index bf047cb..60e4346 100644 --- a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs @@ -519,7 +519,7 @@ public virtual ContainerBuilder SetupMessagingServiceContainer(){ protected IntegrationTestCertificate? IntegrationTestCertificate; - public ContainerBuilder SetupSecurityServiceContainer() + public virtual ContainerBuilder SetupSecurityServiceContainer() { this.Trace("About to Start Security Container"); diff --git a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs index 3951c78..d28ac00 100644 --- a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs @@ -125,36 +125,46 @@ public void DisposeIntegrationTestCertificate() } public override async Task StopContainersForScenarioRun(DockerServices sharedDockerServices) { - if (this.Containers.Any()) { - this.Containers.Reverse(); - - foreach ((DockerServices, IContainer) containerService in this.Containers) { - - if ((sharedDockerServices & containerService.Item1) == containerService.Item1){ - continue; + try + { + if (this.Containers.Any()) { + this.Containers.Reverse(); + + foreach ((DockerServices, IContainer) containerService in this.Containers) { + + if ((sharedDockerServices & containerService.Item1) == containerService.Item1){ + continue; + } + + String? name; + try + { + name = containerService.Item2.Name; + } + catch (InvalidOperationException ex) + { + this.Trace($"Skipping container entry that is no longer available [{containerService.Item1}] ({ex.Message})"); + continue; + } + this.Trace($"Stopping container [{name}]"); + await containerService.Item2.StopAsync(CancellationToken.None); + await containerService.Item2.DisposeAsync(); + this.Trace($"Container [{name}] stopped"); } + } - String? name; - try - { - name = containerService.Item2.Name; + if (this.TestNetworks.Any()) { + foreach (INetwork networkService in this.TestNetworks){ + await networkService.DeleteAsync(CancellationToken.None); + await networkService.DisposeAsync(); } - catch (InvalidOperationException ex) - { - this.Trace($"Skipping container entry that is no longer available [{containerService.Item1}] ({ex.Message})"); - continue; - } - this.Trace($"Stopping container [{name}]"); - await containerService.Item2.StopAsync(CancellationToken.None); - await containerService.Item2.DisposeAsync(); - this.Trace($"Container [{name}] stopped"); } } - - if (this.TestNetworks.Any()) { - foreach (INetwork networkService in this.TestNetworks){ - await networkService.DeleteAsync(CancellationToken.None); - await networkService.DisposeAsync(); + finally + { + if ((sharedDockerServices & DockerServices.SecurityService) != DockerServices.SecurityService) + { + this.DisposeIntegrationTestCertificate(); } } }