From ebf8f6f82c3ec96419f1dc26b029b4eba68b9200 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 10:44:02 +0100 Subject: [PATCH 1/6] some hardening added to the security service test steps --- .../IntegrationTestCertificate.cs | 93 +++++--- .../SecurityServiceSteps.cs | 216 +++++++++++++----- .../ApiResource/ApiResourceSteps.cs | 3 +- .../ApiScopes/ApiScopeSteps.cs | 1 + .../Clients/ClientsSteps.cs | 4 +- .../IdentityResource/IdentityResourceSteps.cs | 3 +- .../Roles/RolesSteps.cs | 1 + .../Users/Users.feature | 3 +- .../Users/Users.feature.cs | 11 +- .../Users/UsersSteps.cs | 1 + .../Common/SharedSteps.cs | 36 +-- 11 files changed, 266 insertions(+), 106 deletions(-) diff --git a/SecurityService.IntegrationTesting.Helpers/IntegrationTestCertificate.cs b/SecurityService.IntegrationTesting.Helpers/IntegrationTestCertificate.cs index 27e9781..8e123e2 100644 --- a/SecurityService.IntegrationTesting.Helpers/IntegrationTestCertificate.cs +++ b/SecurityService.IntegrationTesting.Helpers/IntegrationTestCertificate.cs @@ -40,36 +40,47 @@ private IntegrationTestCertificate(string certificateDirectory, X509Certificate2 public static IntegrationTestCertificate Create() { string certificateDirectory = Path.Combine(Path.GetTempPath(), $"securityservice-cert-{Guid.NewGuid():N}"); - Directory.CreateDirectory(certificateDirectory); + IntegrationTestCertificate? result = null; - 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)); + try + { + 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)); + 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()); + 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)); + 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); + 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); + 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; + result = new IntegrationTestCertificate(certificateDirectory, X509CertificateLoader.LoadPkcs12(pfxBytes, PasswordValue, X509KeyStorageFlags.EphemeralKeySet)); + result.InstallTrustedRoot(); + return result; + } + catch + { + result?.Dispose(); + TryDeleteDirectory(certificateDirectory); + throw; + } } public void InstallTrustedRoot() @@ -94,18 +105,23 @@ public void Dispose() return; } - if (_trustedRootInstalled) + try { - RemoveTrustedRoot(); + if (_trustedRootInstalled) + { + RemoveTrustedRoot(); + } } - - if (Directory.Exists(_certificateDirectory)) + catch { - Directory.Delete(_certificateDirectory, recursive: true); + // Cleanup must not mask the original test failure. + } + finally + { + TryDeleteDirectory(_certificateDirectory); + _certificate.Dispose(); + _disposed = true; } - - _certificate.Dispose(); - _disposed = true; } private void RemoveTrustedRoot() @@ -121,6 +137,21 @@ private void RemoveTrustedRoot() _trustedRootInstalled = false; } + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch + { + // Cleanup must not mask the original test failure. + } + } + private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index 49b8d86..005e2cf 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -14,6 +14,129 @@ public class SecurityServiceSteps{ private readonly ISecurityServiceClient SecurityServiceClient; private readonly string AccessToken; + private static void AssertExpectedResults( + IEnumerable actual, + IEnumerable expected, + Func keySelector, + bool requireExactCount = true) + { + List actualList = actual.ToList(); + List expectedList = expected.ToList(); + + if (requireExactCount) + { + actualList.Count.ShouldBe(expectedList.Count); + } + + actualList.Select(keySelector).Distinct().Count().ShouldBe(actualList.Count); + expectedList.Select(keySelector).Distinct().Count().ShouldBe(expectedList.Count); + } + + private static void AssertStringCollectionsMatch( + IEnumerable actual, + IEnumerable expected) + { + actual.OrderBy(value => value).ShouldBe(expected.OrderBy(value => value)); + } + + private static void AssertUserClaimsContain( + IReadOnlyDictionary actual, + IReadOnlyDictionary expected) + { + foreach (KeyValuePair expectedClaim in expected) + { + String claimName = $"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/{expectedClaim.Key}"; + actual.ContainsKey(claimName).ShouldBeTrue(claimName); + actual[claimName].ShouldBe(expectedClaim.Value); + } + } + + private async Task GetAndVerifyUser(CreateUserRequest request, CancellationToken cancellationToken) + { + Result>? response = await this.SecurityServiceClient + .GetUsers(this.AccessToken, request.EmailAddress, cancellationToken) + .ConfigureAwait(false); + + response.IsSuccess.ShouldBeTrue(response.Message); + UserResponse user = response.Data.Single(); + String expectedUserName = String.IsNullOrWhiteSpace(request.UserName) + ? request.EmailAddress ?? String.Empty + : request.UserName; + + user.UserName.ShouldBe(expectedUserName); + user.EmailAddress.ShouldBe(request.EmailAddress); + user.GivenName.ShouldBe(request.GivenName); + user.MiddleName.ShouldBe(request.MiddleName); + user.FamilyName.ShouldBe(request.FamilyName); + user.PhoneNumber.ShouldBe(request.PhoneNumber); + AssertStringCollectionsMatch(user.Roles, request.Roles); + AssertUserClaimsContain(user.Claims, request.Claims); + + return user; + } + + private async Task VerifyApiScope(CreateApiScopeRequest request) + { + Result? response = await this.SecurityServiceClient + .GetApiScope(this.AccessToken, request.Name, CancellationToken.None) + .ConfigureAwait(false); + + response.IsSuccess.ShouldBeTrue(response.Message); + response.Data.ShouldNotBeNull(); + response.Data.Name.ShouldBe(request.Name); + response.Data.DisplayName.ShouldBe(request.DisplayName); + response.Data.Description.ShouldBe(request.Description); + } + + private async Task VerifyApiResource(CreateApiResourceRequest request) + { + Result? response = await this.SecurityServiceClient + .GetApiResource(this.AccessToken, request.Name, CancellationToken.None) + .ConfigureAwait(false); + + response.IsSuccess.ShouldBeTrue(response.Message); + response.Data.ShouldNotBeNull(); + response.Data.Name.ShouldBe(request.Name); + response.Data.DisplayName.ShouldBe(request.DisplayName); + response.Data.Description.ShouldBe(request.Description); + AssertStringCollectionsMatch(response.Data.Scopes, request.Scopes); + AssertStringCollectionsMatch(response.Data.UserClaims, request.UserClaims); + } + + private async Task VerifyClient(CreateClientRequest request) + { + Result? response = await this.SecurityServiceClient + .GetClient(this.AccessToken, request.ClientId, CancellationToken.None) + .ConfigureAwait(false); + + response.IsSuccess.ShouldBeTrue(response.Message); + response.Data.ShouldNotBeNull(); + response.Data.ClientId.ShouldBe(request.ClientId); + response.Data.ClientName.ShouldBe(request.ClientName); + response.Data.Description.ShouldBe(request.ClientDescription); + response.Data.ClientUri.ShouldBe(request.ClientUri); + response.Data.RequireConsent.ShouldBe(request.RequireConsent); + response.Data.AllowOfflineAccess.ShouldBe(request.AllowOfflineAccess); + AssertStringCollectionsMatch(response.Data.AllowedScopes, request.AllowedScopes); + AssertStringCollectionsMatch(response.Data.AllowedGrantTypes, request.AllowedGrantTypes); + AssertStringCollectionsMatch(response.Data.ClientRedirectUris, request.ClientRedirectUris); + AssertStringCollectionsMatch(response.Data.ClientPostLogoutRedirectUris, request.ClientPostLogoutRedirectUris); + } + + private async Task VerifyIdentityResource(CreateIdentityResourceRequest request) + { + Result? response = await this.SecurityServiceClient + .GetIdentityResource(this.AccessToken, request.Name, CancellationToken.None) + .ConfigureAwait(false); + + response.IsSuccess.ShouldBeTrue(response.Message); + response.Data.ShouldNotBeNull(); + response.Data.Name.ShouldBe(request.Name); + response.Data.DisplayName.ShouldBe(request.DisplayName); + response.Data.Description.ShouldBe(request.Description); + AssertStringCollectionsMatch(response.Data.Claims, request.Claims ?? Enumerable.Empty()); + } + public SecurityServiceSteps(ISecurityServiceClient securityServiceClient, String accessToken){ this.SecurityServiceClient = securityServiceClient; this.AccessToken = accessToken; @@ -28,8 +151,7 @@ private async Task CreateApiScope(String accessToken, CreateApiScopeRequest crea public async Task GivenICreateTheFollowingApiScopes(List createApiScopeRequests){ foreach (CreateApiScopeRequest createApiScopeRequest in createApiScopeRequests){ await this.CreateApiScope(this.AccessToken, createApiScopeRequest, CancellationToken.None).ConfigureAwait(false); - - // TODO: can do a get in here to verify really created... + await this.VerifyApiScope(createApiScopeRequest).ConfigureAwait(false); } } @@ -56,7 +178,8 @@ public async Task GivenTheFollowingApiResourcesExist(DataTable table){ splitUserClaims.ForEach(a => { createApiResourceRequest.UserClaims.Add(a.Trim()); }); Result? result = await this.SecurityServiceClient.CreateApiResource(this.AccessToken, createApiResourceRequest, CancellationToken.None).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); + result.IsSuccess.ShouldBeTrue(result.Message); + await this.VerifyApiResource(createApiResourceRequest).ConfigureAwait(false); } } @@ -64,9 +187,9 @@ public async Task GivenTheFollowingApiResourcesExist(DataTable table){ List<(String clientId, String secret, List allowedGrantTypes)> clients = new List<(String clientId, String secret, List allowedGrantTypes)>(); foreach (CreateClientRequest createClientRequest in createClientRequests){ Result? result = await this.SecurityServiceClient.CreateClient(this.AccessToken, createClientRequest, CancellationToken.None).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); - - // TODO: What do i do here.... + result.IsSuccess.ShouldBeTrue(result.Message); + await this.VerifyClient(createClientRequest).ConfigureAwait(false); + clients.Add((createClientRequest.ClientId, createClientRequest.Secret, createClientRequest.AllowedGrantTypes)); } @@ -76,7 +199,8 @@ public async Task GivenTheFollowingApiResourcesExist(DataTable table){ public async Task GivenTheFollowingApiResourcesExist(List requests){ foreach (CreateApiResourceRequest createApiResourceRequest in requests){ Result? result = await this.SecurityServiceClient.CreateApiResource(this.AccessToken, createApiResourceRequest, CancellationToken.None).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); + result.IsSuccess.ShouldBeTrue(result.Message); + await this.VerifyApiResource(createApiResourceRequest).ConfigureAwait(false); } } @@ -101,18 +225,14 @@ public async Task WhenIGetTheApiResourcesApiResourceDetailsAreReturnedAsFollows( Result>? apiResourceDetailsListResult = await this.SecurityServiceClient.GetApiResources(this.AccessToken, cancellationToken).ConfigureAwait(false); apiResourceDetailsListResult.IsSuccess.ShouldBeTrue(); List apiResourceDetailsList = apiResourceDetailsListResult.Data; + AssertExpectedResults(apiResourceDetailsList, expectedDetails, resource => resource.Name); foreach (ApiResourceResponse apiResourceDetails in expectedDetails){ ApiResourceResponse? foundRecord = apiResourceDetailsList.SingleOrDefault(a => a.Name == apiResourceDetails.Name); foundRecord.ShouldNotBeNull(); foundRecord.Description.ShouldBe(apiResourceDetails.Description); foundRecord.DisplayName.ShouldBe(apiResourceDetails.DisplayName); - foreach (String? scope in apiResourceDetails.Scopes){ - foundRecord.Scopes.ShouldContain(scope); - } - - foreach (String? userClaim in apiResourceDetails.UserClaims){ - foundRecord.UserClaims.ShouldContain(userClaim); - } + AssertStringCollectionsMatch(foundRecord.Scopes, apiResourceDetails.Scopes); + AssertStringCollectionsMatch(foundRecord.UserClaims, apiResourceDetails.UserClaims); } } @@ -126,13 +246,8 @@ public async Task WhenIGetTheApiResourceWithNameTheApiResourceDetailsAreReturned apiResourceDetails.ShouldNotBeNull(); apiResourceDetails.Description.ShouldBe(expectedRecord.Description); apiResourceDetails.DisplayName.ShouldBe(expectedRecord.DisplayName); - foreach (String? scope in expectedRecord.Scopes){ - apiResourceDetails.Scopes.ShouldContain(scope); - } - - foreach (String? userClaim in expectedRecord.UserClaims){ - apiResourceDetails.UserClaims.ShouldContain(userClaim); - } + AssertStringCollectionsMatch(apiResourceDetails.Scopes, expectedRecord.Scopes); + AssertStringCollectionsMatch(apiResourceDetails.UserClaims, expectedRecord.UserClaims); } public async Task WhenIGetTheApiScopesApiScopeDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken){ @@ -140,6 +255,7 @@ public async Task WhenIGetTheApiScopesApiScopeDetailsAreReturnedAsFollows(List? apiScopeDetailsList = apiScopeDetailsListResult.Data; + AssertExpectedResults(apiScopeDetailsList!, expectedDetails, scope => scope.Name); foreach (ApiScopeResponse apiScopeDetails in expectedDetails){ ApiScopeResponse? foundRecord = apiScopeDetailsList.SingleOrDefault(a => a.Name == apiScopeDetails.Name); foundRecord.ShouldNotBeNull(); @@ -161,7 +277,7 @@ public async Task WhenIGetTheApiScopeWithNameTheApiScopeDetailsAreReturnedAsFoll } public async Task WhenIGetTheClientWithClientIdTheClientDetailsAreReturnedAsFollows(List expectedDetails, String clientId, CancellationToken cancellationToken){ - Result? clientDetailsResult = await this.SecurityServiceClient.GetClient(this.AccessToken, clientId, CancellationToken.None).ConfigureAwait(false); + Result? clientDetailsResult = await this.SecurityServiceClient.GetClient(this.AccessToken, clientId, cancellationToken).ConfigureAwait(false); clientDetailsResult.IsSuccess.ShouldBeTrue(); ClientResponse clientDetails = clientDetailsResult.Data; ClientResponse expectedRecord = expectedDetails.Single(); @@ -170,19 +286,15 @@ public async Task WhenIGetTheClientWithClientIdTheClientDetailsAreReturnedAsFoll clientDetails.ClientId.ShouldBe(expectedRecord.ClientId); clientDetails.ClientName.ShouldBe(expectedRecord.ClientName); clientDetails.Description.ShouldBe(expectedRecord.Description); - foreach (String expectedRecordAllowedGrantType in expectedRecord.AllowedGrantTypes){ - clientDetails.AllowedGrantTypes.ShouldContain(expectedRecordAllowedGrantType); - } - - foreach (String expectedRecordAllowedScope in expectedRecord.AllowedScopes){ - clientDetails.AllowedScopes.ShouldContain(expectedRecordAllowedScope); - } + AssertStringCollectionsMatch(clientDetails.AllowedGrantTypes, expectedRecord.AllowedGrantTypes); + AssertStringCollectionsMatch(clientDetails.AllowedScopes, expectedRecord.AllowedScopes); } public async Task WhenIGetTheClientsClientsDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken){ - Result>? clientDetailsListResult = await this.SecurityServiceClient.GetClients(this.AccessToken, CancellationToken.None).ConfigureAwait(false); + Result>? clientDetailsListResult = await this.SecurityServiceClient.GetClients(this.AccessToken, cancellationToken).ConfigureAwait(false); clientDetailsListResult.IsSuccess.ShouldBeTrue(); List? clientDetailsList = clientDetailsListResult.Data; + AssertExpectedResults(clientDetailsList!, expectedDetails, client => client.ClientId); foreach (ClientResponse expectedRecord in expectedDetails){ ClientResponse? foundRecord = clientDetailsList.SingleOrDefault(a => a.ClientId == expectedRecord.ClientId); foundRecord.ShouldNotBeNull(); @@ -190,20 +302,16 @@ public async Task WhenIGetTheClientsClientsDetailsAreReturnedAsFollows(List requests, CancellationToken cancellationToken){ foreach (CreateIdentityResourceRequest createIdentityResourceRequest in requests){ Result? result = await this.SecurityServiceClient.CreateIdentityResource(this.AccessToken, createIdentityResourceRequest, cancellationToken).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); + result.IsSuccess.ShouldBeTrue(result.Message); + await this.VerifyIdentityResource(createIdentityResourceRequest).ConfigureAwait(false); } } @@ -218,15 +326,14 @@ public async Task WhenIGetTheIdentityResourceWithNameTheIdentityResourceDetailsA identityResourceDetails.Description.ShouldBe(expectedRecord.Description); identityResourceDetails.DisplayName.ShouldBe(expectedRecord.DisplayName); - foreach (String expectedRecordClaim in expectedRecord.Claims){ - identityResourceDetails.Claims.ShouldContain(expectedRecordClaim); - } + AssertStringCollectionsMatch(identityResourceDetails.Claims, expectedRecord.Claims); } public async Task WhenIGetTheIdentityResourcesIdentityResourceDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken){ - Result>? getIdentityResourcesResult = await this.SecurityServiceClient.GetIdentityResources(this.AccessToken, CancellationToken.None).ConfigureAwait(false); + Result>? getIdentityResourcesResult = await this.SecurityServiceClient.GetIdentityResources(this.AccessToken, cancellationToken).ConfigureAwait(false); getIdentityResourcesResult.IsSuccess.ShouldBeTrue(); List? identityResourceDetailsList = getIdentityResourcesResult.Data; + AssertExpectedResults(identityResourceDetailsList!, expectedDetails, resource => resource.Name); foreach (IdentityResourceResponse expectedRecord in expectedDetails){ IdentityResourceResponse? foundRecord = identityResourceDetailsList.SingleOrDefault(a => a.Name == expectedRecord.Name); foundRecord.ShouldNotBeNull(); @@ -234,10 +341,7 @@ public async Task WhenIGetTheIdentityResourcesIdentityResourceDetailsAreReturned foundRecord.Description.ShouldBe(expectedRecord.Description); foundRecord.DisplayName.ShouldBe(expectedRecord.DisplayName); - foreach (String expectedRecordClaim in expectedRecord.Claims) - { - foundRecord.Claims.ShouldContain(expectedRecordClaim); - } + AssertStringCollectionsMatch(foundRecord.Claims, expectedRecord.Claims); } } @@ -252,8 +356,13 @@ public async Task WhenIGetTheIdentityResourcesIdentityResourceDetailsAreReturned Result>? roles = await this.SecurityServiceClient.GetRoles(this.AccessToken, cancellationToken); roles.IsSuccess.ShouldBeTrue(roles.Message); + roles.Data.Count.ShouldBe(requests.Count); + roles.Data.Select(role => role.RoleName).Distinct().Count().ShouldBe(roles.Data.Count); + requests.Select(request => request.RoleName).Distinct().Count().ShouldBe(requests.Count); + foreach (CreateRoleRequest request in requests) { RoleResponse r = roles.Data.Single(r => r.RoleName == request.RoleName); + r.RoleId.ShouldNotBeNullOrWhiteSpace(); roleList.Add((r.RoleName, r.RoleId)); } @@ -272,9 +381,10 @@ public async Task WhenIGetTheRoleWithNameTheRoleDetailsAreReturnedAsFollows(List public async Task WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken) { - Result>? getRolesResult = await this.SecurityServiceClient.GetRoles(this.AccessToken, CancellationToken.None).ConfigureAwait(false); + Result>? getRolesResult = await this.SecurityServiceClient.GetRoles(this.AccessToken, cancellationToken).ConfigureAwait(false); getRolesResult.IsSuccess.ShouldBeTrue(); List? rolesList = getRolesResult.Data; + AssertExpectedResults(rolesList!, expectedDetails, role => role.RoleName); foreach (RoleResponse expectedRecord in expectedDetails) { RoleResponse? foundRecord = rolesList.SingleOrDefault(a => a.RoleName == expectedRecord.RoleName); @@ -287,15 +397,14 @@ public async Task WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(List results = new List<(String, String)>(); foreach (CreateUserRequest createUserRequest in requests){ Result? result = await this.SecurityServiceClient.CreateUser(this.AccessToken, createUserRequest, cancellationToken).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); + result.IsSuccess.ShouldBeTrue(result.Message); - Result>? user = await this.SecurityServiceClient.GetUsers(this.AccessToken, createUserRequest.EmailAddress, cancellationToken); - user.IsSuccess.ShouldBeTrue(); + UserResponse user = await this.GetAndVerifyUser(createUserRequest, cancellationToken).ConfigureAwait(false); String userKey = String.IsNullOrWhiteSpace(createUserRequest.UserName) ? createUserRequest.EmailAddress ?? String.Empty : createUserRequest.UserName; - results.Add((userKey, user.Data.Single().UserId)); + results.Add((userKey, user.UserId)); } return results; } @@ -314,9 +423,10 @@ public Task RemoveRoleMfa(String roleId, CancellationToken cancellationT public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken) { - Result>? getUsersResult = await this.SecurityServiceClient.GetUsers(this.AccessToken, String.Empty, CancellationToken.None).ConfigureAwait(false); + Result>? getUsersResult = await this.SecurityServiceClient.GetUsers(this.AccessToken, String.Empty, cancellationToken).ConfigureAwait(false); getUsersResult.IsSuccess.ShouldBeTrue(); List? usersList = getUsersResult.Data; + AssertExpectedResults(usersList!, expectedDetails, user => user.UserName); foreach (UserResponse expectedRecord in expectedDetails) { UserResponse? foundRecord = usersList.SingleOrDefault(a => a.UserName == expectedRecord.UserName); @@ -338,7 +448,7 @@ public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(List expectedDetails, String userId, CancellationToken cancellationToken){ - var userDetails = await this.SecurityServiceClient.GetUser(this.AccessToken, userId, CancellationToken.None).ConfigureAwait(false); + var userDetails = await this.SecurityServiceClient.GetUser(this.AccessToken, userId, cancellationToken).ConfigureAwait(false); var expectedRecord = expectedDetails.Single(); userDetails.IsSuccess.ShouldBeTrue(); diff --git a/SecurityService.IntegrationTests/ApiResource/ApiResourceSteps.cs b/SecurityService.IntegrationTests/ApiResource/ApiResourceSteps.cs index 3d5c889..04afcf8 100644 --- a/SecurityService.IntegrationTests/ApiResource/ApiResourceSteps.cs +++ b/SecurityService.IntegrationTests/ApiResource/ApiResourceSteps.cs @@ -68,6 +68,7 @@ public async Task GivenICreateTheFollowingApiResources(Table table) [When(@"I get the api resources (.*) api resource details are returned as follows")] public async Task WhenIGetTheApiResourcesApiResourceDetailsAreReturnedAsFollows(Int32 numberOfApiResources, Table table){ + table.Rows.Count.ShouldBe(numberOfApiResources); List expectedDetails = table.Rows.ToApiResourceResponses(); await this.SecurityServiceSteps.WhenIGetTheApiResourcesApiResourceDetailsAreReturnedAsFollows(expectedDetails, CancellationToken.None); } @@ -90,4 +91,4 @@ public async Task WhenIGetTheApiResourceWithNameTheApiResourceDetailsAreReturned #endregion } -} \ No newline at end of file +} diff --git a/SecurityService.IntegrationTests/ApiScopes/ApiScopeSteps.cs b/SecurityService.IntegrationTests/ApiScopes/ApiScopeSteps.cs index 52a9fc9..44c75d9 100644 --- a/SecurityService.IntegrationTests/ApiScopes/ApiScopeSteps.cs +++ b/SecurityService.IntegrationTests/ApiScopes/ApiScopeSteps.cs @@ -76,6 +76,7 @@ public async Task WhenIGetTheApiScopeWithNameTheApiScopeDetailsAreReturnedAsFoll [When(@"I get the api scopes (.*) api scope details are returned as follows")] public async Task WhenIGetTheApiScopesApiScopeDetailsAreReturnedAsFollows(Int32 numberOfApiScopes, DataTable table) { + table.Rows.Count.ShouldBe(numberOfApiScopes); List expectedDetails = table.Rows.ToApiScopeResponses(); await this.SecurityServiceSteps.WhenIGetTheApiScopesApiScopeDetailsAreReturnedAsFollows(expectedDetails, CancellationToken.None); } diff --git a/SecurityService.IntegrationTests/Clients/ClientsSteps.cs b/SecurityService.IntegrationTests/Clients/ClientsSteps.cs index 45c8e7c..e2116ce 100644 --- a/SecurityService.IntegrationTests/Clients/ClientsSteps.cs +++ b/SecurityService.IntegrationTests/Clients/ClientsSteps.cs @@ -11,6 +11,7 @@ using IntegrationTesting.Helpers; using IntergrationTests.Common; using Reqnroll; + using Shouldly; /// /// @@ -69,6 +70,7 @@ public async Task GivenICreateTheFollowingClients(DataTable table) [When(@"I get the clients (.*) clients details are returned as follows")] public async Task WhenIGetTheClientsClientsDetailsAreReturnedAsFollows(Int32 numberOfClients, DataTable table){ + table.Rows.Count.ShouldBe(numberOfClients); List expectedClientDetails = table.Rows.ToClientResponses(); await this.SecurityServiceSteps.WhenIGetTheClientsClientsDetailsAreReturnedAsFollows(expectedClientDetails, CancellationToken.None); } @@ -86,4 +88,4 @@ public async Task WhenIGetTheClientWithClientIdTheClientDetailsAreReturnedAsFoll } #endregion } -} \ No newline at end of file +} diff --git a/SecurityService.IntegrationTests/IdentityResource/IdentityResourceSteps.cs b/SecurityService.IntegrationTests/IdentityResource/IdentityResourceSteps.cs index aae6f4f..52839b8 100644 --- a/SecurityService.IntegrationTests/IdentityResource/IdentityResourceSteps.cs +++ b/SecurityService.IntegrationTests/IdentityResource/IdentityResourceSteps.cs @@ -61,8 +61,9 @@ public async Task WhenIGetTheIdentityResourceWithNameTheIdentityResourceDetailsA [When(@"I get the identity resources (.*) identity resource details are returned as follows")] public async Task WhenIGetTheIdentityResourcesIdentityResourceDetailsAreReturnedAsFollows(Int32 numberOfIdentityResources, DataTable table) { + table.Rows.Count.ShouldBe(numberOfIdentityResources); List expectedIdentityResourceDetailsList = table.Rows.ToIdentityResourceResponses(); await this.SecurityServiceSteps.WhenIGetTheIdentityResourcesIdentityResourceDetailsAreReturnedAsFollows(expectedIdentityResourceDetailsList, CancellationToken.None); } } -} \ No newline at end of file +} diff --git a/SecurityService.IntegrationTests/Roles/RolesSteps.cs b/SecurityService.IntegrationTests/Roles/RolesSteps.cs index f48441b..439569b 100644 --- a/SecurityService.IntegrationTests/Roles/RolesSteps.cs +++ b/SecurityService.IntegrationTests/Roles/RolesSteps.cs @@ -62,6 +62,7 @@ public async Task WhenIGetTheRoleWithNameTheRoleDetailsAreReturnedAsFollows(Stri [When(@"I get the roles (.*) roles details are returned as follows")] public async Task WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(Int32 numberOfRoles, Table table) { + table.Rows.Count.ShouldBe(numberOfRoles); List requests = table.Rows.ToRoleResponses(); await this.SecurityServiceSteps.WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(requests, CancellationToken.None); } diff --git a/SecurityService.IntegrationTests/Users/Users.feature b/SecurityService.IntegrationTests/Users/Users.feature index 0bdabc0..b9c8a04 100644 --- a/SecurityService.IntegrationTests/Users/Users.feature +++ b/SecurityService.IntegrationTests/Users/Users.feature @@ -39,8 +39,9 @@ Scenario: Require and remove MFA for a user Then the user MFA policy operation succeeds When I remove MFA for user 'mfauser@testing.co.uk' Then the user MFA policy operation succeeds - When I get the users 3 users details are returned as follows + When I get the users 4 users details are returned as follows | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles |RegistrationDate | | testuser1@testing.co.uk | 123456789 | Test | | User 1 | emailaddress:testuser1@testing.co.uk, givenname:Test, surname:User 1 | TestRole1 |Today | | testuser2@testing.co.uk | 123456789 | Test | | User 2 | emailaddress:testuser2@testing.co.uk, givenname:Test, surname:User 2 | TestRole2 |Today | | testuser3@testing.co.uk | 123456789 | Test | | User 3 | emailaddress:testuser3@testing.co.uk, givenname:Test, surname:User 3 | TestRole3 |Today | + | mfauser@testing.co.uk | 123456789 | MFA | | User | emailaddress:mfauser@testing.co.uk, givenname:MFA, surname:User | TestRole1 |Today | diff --git a/SecurityService.IntegrationTests/Users/Users.feature.cs b/SecurityService.IntegrationTests/Users/Users.feature.cs index 12263e1..cf93845 100644 --- a/SecurityService.IntegrationTests/Users/Users.feature.cs +++ b/SecurityService.IntegrationTests/Users/Users.feature.cs @@ -383,8 +383,17 @@ await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co "emailaddress:testuser3@testing.co.uk, givenname:Test, surname:User 3", "TestRole3", "Today"}); + table7.AddRow(new string[] { + "mfauser@testing.co.uk", + "123456789", + "MFA", + "", + "User", + "emailaddress:mfauser@testing.co.uk, givenname:MFA, surname:User", + "TestRole1", + "Today"}); #line 42 - await testRunner.WhenAsync("I get the users 3 users details are returned as follows", ((string)(null)), table7, "When "); + await testRunner.WhenAsync("I get the users 4 users details are returned as follows", ((string)(null)), table7, "When "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/SecurityService.IntegrationTests/Users/UsersSteps.cs b/SecurityService.IntegrationTests/Users/UsersSteps.cs index fcb33d6..0f74072 100644 --- a/SecurityService.IntegrationTests/Users/UsersSteps.cs +++ b/SecurityService.IntegrationTests/Users/UsersSteps.cs @@ -65,6 +65,7 @@ public async Task GivenICreateTheFollowingUsers(DataTable table){ [When(@"I get the users (.*) users details are returned as follows")] public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(Int32 numberOfUsers, DataTable table){ + table.Rows.Count.ShouldBe(numberOfUsers); List userDetailsList = table.Rows.ToUserResponses(); await this.SecurityServiceSteps.WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(userDetailsList, CancellationToken.None); } diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/SharedSteps.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/SharedSteps.cs index 6fd6462..f4f45a3 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/SharedSteps.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/SharedSteps.cs @@ -78,28 +78,30 @@ private async Task CreateIdentityResource(CreateIdentityResourceRequest createId Result> identityResourceListResult = await this.TestingContext.DockerHelper.SecurityServiceClient.GetIdentityResources(this.TestingContext.DockerHelper.AccessToken,cancellationToken); identityResourceListResult.IsSuccess.ShouldBeTrue(); List identityResourceList = identityResourceListResult.Data; - if (identityResourceList == null || identityResourceList.Any() == false) { - Result result = await this.TestingContext.DockerHelper.SecurityServiceClient.CreateIdentityResource(this.TestingContext.DockerHelper.AccessToken,createIdentityResourceRequest, cancellationToken).ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); + IdentityResourceResponse existingIdentityResource = identityResourceList? + .SingleOrDefault(identityResource => identityResource.Name == createIdentityResourceRequest.Name); - this.TestingContext.IdentityResources.Add(createIdentityResourceRequest.Name); - } - else + if (existingIdentityResource is not null) { - if (identityResourceList.Any(i => i.Name == createIdentityResourceRequest.Name)) - { - return; - } - - Result result = await this - .TestingContext.DockerHelper.SecurityServiceClient - .CreateIdentityResource(this.TestingContext.DockerHelper.AccessToken,createIdentityResourceRequest, cancellationToken) - .ConfigureAwait(false); - result.IsSuccess.ShouldBeTrue(); - + existingIdentityResource.DisplayName.ShouldBe(createIdentityResourceRequest.DisplayName); + existingIdentityResource.Description.ShouldBe(createIdentityResourceRequest.Description); + + List expectedClaims = createIdentityResourceRequest.Claims ?? new List(); + existingIdentityResource.Claims + .OrderBy(claim => claim) + .ShouldBe(expectedClaims.OrderBy(claim => claim)); this.TestingContext.IdentityResources.Add(createIdentityResourceRequest.Name); + return; } + + Result result = await this + .TestingContext.DockerHelper.SecurityServiceClient + .CreateIdentityResource(this.TestingContext.DockerHelper.AccessToken,createIdentityResourceRequest, cancellationToken) + .ConfigureAwait(false); + result.IsSuccess.ShouldBeTrue(result.Message); + + this.TestingContext.IdentityResources.Add(createIdentityResourceRequest.Name); } [Given(@"I create the following clients")] From 0c92624e2c2d5f237dcc0d9ad7e0b2a82df6e0b2 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 13:24:59 +0100 Subject: [PATCH 2/6] harden the security service setup flow --- Directory.Packages.props | 18 +-- .../Common/DockerHelper.cs | 103 ++++++++---------- .../Common/GenericSteps.cs | 18 +-- .../Common/DockerHelper.cs | 29 ++--- .../Common/GenericSteps.cs | 6 +- 5 files changed, 82 insertions(+), 92 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1eb0123..e71a299 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,17 +3,17 @@ true - + - + - - + + @@ -28,7 +28,7 @@ - + @@ -38,7 +38,7 @@ - + @@ -48,8 +48,8 @@ - - + + @@ -58,4 +58,4 @@ - + \ No newline at end of file diff --git a/SecurityService.IntegrationTests/Common/DockerHelper.cs b/SecurityService.IntegrationTests/Common/DockerHelper.cs index ecfb28b..63f4964 100644 --- a/SecurityService.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.IntegrationTests/Common/DockerHelper.cs @@ -20,7 +20,6 @@ namespace SecurityService.IntergrationTests.Common public class DockerHelper : Shared.IntegrationTesting.TestContainers.DockerHelper { public ISecurityServiceClient? SecurityServiceClient; - private IntegrationTestCertificate? _integrationTestCertificate; String Serialise(Object arg) { @@ -33,39 +32,39 @@ Object Deserialise(String arg, Type type) } public String AccessToken; - public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices) - { - this._integrationTestCertificate = IntegrationTestCertificate.Create(); - - try - { - await base.StartContainersForScenarioRun(scenarioName, dockerServices); - - Func securityServiceBaseAddressResolver = _ => $"https://localhost:{this.SecurityServicePort}"; - HttpClient httpClient = this.CreatePinnedHttpClient(); - this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); - - SimpleResults.Result bootstrapToken = await this.SecurityServiceClient.GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None); - if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) - { - throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); - } - this.AccessToken = bootstrapToken.Data.AccessToken; - - ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; - } - catch - { - this.DisposeIntegrationTestCertificate(); - throw; - } - } + //public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices, ) + //{ + // this._integrationTestCertificate = IntegrationTestCertificate.Create(); + + // try + // { + // await base.StartContainersForScenarioRun(scenarioName, dockerServices); + + // Func securityServiceBaseAddressResolver = _ => $"https://localhost:{this.SecurityServicePort}"; + // HttpClient httpClient = this.CreatePinnedHttpClient(); + // this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); + + // SimpleResults.Result bootstrapToken = await this.SecurityServiceClient.GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None); + // if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) + // { + // throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); + // } + // this.AccessToken = bootstrapToken.Data.AccessToken; + + // ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + // } + // catch + // { + // this.DisposeIntegrationTestCertificate(); + // throw; + // } + //} public override ContainerBuilder SetupSecurityServiceContainer() { this.Trace("About to Start Security Container"); - if (this._integrationTestCertificate is null) + if (this.IntegrationTestCertificate is null) { throw new InvalidOperationException("Integration test certificate was not initialised before container setup."); } @@ -75,8 +74,8 @@ public override ContainerBuilder SetupSecurityServiceContainer() 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: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"); @@ -111,7 +110,7 @@ public override ContainerBuilder SetupSecurityServiceContainer() ContainerBuilder securityServiceContainer = new ContainerBuilder(imageDetailsResult.Data.imageName) .WithName(this.SecurityServiceContainerName) .WithEnvironment(environmentVariables) - .WithBindMount(this._integrationTestCertificate.CertificateDirectory, this._integrationTestCertificate.ContainerCertificateDirectory, AccessMode.ReadOnly); + .WithBindMount(this.IntegrationTestCertificate.CertificateDirectory, this.IntegrationTestCertificate.ContainerCertificateDirectory, AccessMode.ReadOnly); Int32? hostPort = this.GetHostPort(ContainerType.SecurityService); if (hostPort is null || hostPort <= 0) @@ -126,32 +125,26 @@ public override ContainerBuilder SetupSecurityServiceContainer() return securityServiceContainer; } - public override Task CreateSubscriptions() + public override Task CreateSubscriptions(CancellationToken cancellationToken) { return Task.CompletedTask; } - public void DisposeIntegrationTestCertificate() - { - this._integrationTestCertificate?.Dispose(); - this._integrationTestCertificate = null; - } - - private HttpClient CreatePinnedHttpClient() - { - if (this._integrationTestCertificate is null) - { - throw new InvalidOperationException("Integration test certificate was not initialised before HttpClient creation."); - } - - HttpClientHandler handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, certificate, _, _) => - certificate is X509Certificate2 certificate2 && - String.Equals(certificate2.Thumbprint, this._integrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) - }; - - return new HttpClient(handler, disposeHandler: true); - } + //private HttpClient CreatePinnedHttpClient() + //{ + // if (this.IntegrationTestCertificate is null) + // { + // throw new InvalidOperationException("Integration test certificate was not initialised before HttpClient creation."); + // } + + // HttpClientHandler handler = new HttpClientHandler + // { + // ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + // certificate is X509Certificate2 certificate2 && + // String.Equals(certificate2.Thumbprint, this.IntegrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) + // }; + + // return new HttpClient(handler, disposeHandler: true); + //} } } diff --git a/SecurityService.IntegrationTests/Common/GenericSteps.cs b/SecurityService.IntegrationTests/Common/GenericSteps.cs index 4a144ba..bc10bf1 100644 --- a/SecurityService.IntegrationTests/Common/GenericSteps.cs +++ b/SecurityService.IntegrationTests/Common/GenericSteps.cs @@ -4,17 +4,18 @@ namespace SecurityService.IntergrationTests.Common { + using Ductus.FluentDocker.Builders; + using Ductus.FluentDocker.Common; + using Ductus.FluentDocker.Model.Builders; + using Ductus.FluentDocker.Services; + using Ductus.FluentDocker.Services.Extensions; using NLog; + using Reqnroll; using Shared.IntegrationTesting; using Shared.Logger; - using System.Threading.Tasks; - using Ductus.FluentDocker.Services; - using Ductus.FluentDocker.Model.Builders; - using Ductus.FluentDocker.Services.Extensions; using System.IO; - using Ductus.FluentDocker.Builders; - using Ductus.FluentDocker.Common; - using Reqnroll; + using System.Threading; + using System.Threading.Tasks; [Binding] [Scope(Tag = "base")] @@ -58,7 +59,8 @@ public async Task StartSystem() this.TestingContext.Logger = logger; this.TestingContext.Logger.LogInformation("About to Start Containers for Scenario Run"); - await this.TestingContext.DockerHelper.StartContainersForScenarioRun(scenarioName, dockerServices).ConfigureAwait(false); + await this.TestingContext.DockerHelper.StartContainersForScenarioRun(scenarioName, dockerServices, + CancellationToken.None).ConfigureAwait(false); this.TestingContext.Logger.LogInformation("Containers for Scenario Run Started"); } diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs index 99dd7c9..db77680 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs @@ -39,7 +39,6 @@ public class DockerHelper : Shared.IntegrationTesting.TestContainers.DockerHelpe /// public ISecurityServiceClient SecurityServiceClient; public HttpClient httpClient = new HttpClient(); - private IntegrationTestCertificate? _integrationTestCertificate; public String AccessToken; /// /// The security service test UI container name @@ -79,7 +78,8 @@ public override void SetupContainerNames() this.SecurityServiceTestUIContainerName = $"securityservicetestui{this.TestId:N}"; } - public override async Task CreateSubscriptions(){ + public override async Task CreateSubscriptions(CancellationToken cancellationToken) + { // Nothing to set up here } @@ -97,14 +97,13 @@ Object Deserialise(String arg, Type type) /// Starts the containers for scenario run. /// /// Name of the scenario. - public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices) + public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices, CancellationToken cancellationToken) { this.Trace($"Test Id is {this.TestId} and Scenario {scenarioName}"); - this._integrationTestCertificate = IntegrationTestCertificate.Create(); try { - await base.StartContainersForScenarioRun(scenarioName,dockerServices); + await base.StartContainersForScenarioRun(scenarioName,dockerServices, cancellationToken); await StartContainer(SetupSecurityServiceTestUIContainer, this.TestNetworks); @@ -254,7 +253,7 @@ public override ContainerBuilder SetupSecurityServiceContainer() { this.Trace("About to Start Security Container"); - if (this._integrationTestCertificate is null) + if (this.IntegrationTestCertificate is null) { throw new InvalidOperationException("Integration test certificate was not initialised before container setup."); } @@ -264,8 +263,8 @@ public override ContainerBuilder SetupSecurityServiceContainer() 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: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"); @@ -298,21 +297,15 @@ public override ContainerBuilder SetupSecurityServiceContainer() .WithName(this.SecurityServiceContainerName) .WithEnvironment(environmentVariables) .WithPortBinding(DockerPorts.SecurityServiceDockerPort, DockerPorts.SecurityServiceDockerPort) - .WithBindMount(this._integrationTestCertificate.CertificateDirectory, this._integrationTestCertificate.ContainerCertificateDirectory, AccessMode.ReadOnly) - .MountHostFolder(this.DockerPlatform, this.HostTraceFolder); + .WithBindMount(this.IntegrationTestCertificate.CertificateDirectory, this.IntegrationTestCertificate.ContainerCertificateDirectory, AccessMode.ReadOnly) + .MountHostFolder(this.HostTraceFolder); return securityServiceContainer; } - public void DisposeIntegrationTestCertificate() - { - this._integrationTestCertificate?.Dispose(); - this._integrationTestCertificate = null; - } - private HttpClient CreatePinnedHttpClient() { - if (this._integrationTestCertificate is null) + if (this.IntegrationTestCertificate is null) { throw new InvalidOperationException("Integration test certificate was not initialised before HttpClient creation."); } @@ -321,7 +314,7 @@ private HttpClient CreatePinnedHttpClient() { ServerCertificateCustomValidationCallback = (_, certificate, _, _) => certificate is X509Certificate2 certificate2 && - String.Equals(certificate2.Thumbprint, this._integrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) + String.Equals(certificate2.Thumbprint, this.IntegrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) }; return new HttpClient(handler, disposeHandler: true); diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs index 5d77973..495f811 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs @@ -4,11 +4,12 @@ namespace SecurityService.IntergrationTests.Common { - using System.Threading.Tasks; using NLog; using Reqnroll; using Shared.IntegrationTesting; using Shared.Logger; + using System.Threading; + using System.Threading.Tasks; [Binding] [Scope(Tag = "base")] @@ -52,7 +53,8 @@ public async Task StartSystem() this.TestingContext.Logger = logger; this.TestingContext.Logger.LogInformation("About to Start Containers for Scenario Run"); - await this.TestingContext.DockerHelper.StartContainersForScenarioRun(scenarioName, dockerServices).ConfigureAwait(false); + await this.TestingContext.DockerHelper.StartContainersForScenarioRun(scenarioName, dockerServices, + CancellationToken.None).ConfigureAwait(false); this.TestingContext.Logger.LogInformation("Containers for Scenario Run Started"); } From e06804d4d4b7154af08ef2b270cb2abda80527b7 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 14:01:02 +0100 Subject: [PATCH 3/6] fix integration test setup and custom claim checks --- .../SecurityServiceSteps.cs | 15 ++-- .../Common/DockerHelper.cs | 89 ++++++++++--------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index 005e2cf..da8999e 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -45,9 +45,8 @@ private static void AssertUserClaimsContain( { foreach (KeyValuePair expectedClaim in expected) { - String claimName = $"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/{expectedClaim.Key}"; - actual.ContainsKey(claimName).ShouldBeTrue(claimName); - actual[claimName].ShouldBe(expectedClaim.Value); + actual.ContainsKey(expectedClaim.Key).ShouldBeTrue(expectedClaim.Key); + actual[expectedClaim.Key].ShouldBe(expectedClaim.Value); } } @@ -439,9 +438,8 @@ public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(List expectedRecordClaim in expectedRecord.Claims){ - var claimName = $"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/{expectedRecordClaim.Key}"; - foundRecord.Claims.ContainsKey(claimName).ShouldBeTrue(claimName); - String? claim = foundRecord.Claims[claimName]; + foundRecord.Claims.ContainsKey(expectedRecordClaim.Key).ShouldBeTrue(expectedRecordClaim.Key); + String? claim = foundRecord.Claims[expectedRecordClaim.Key]; claim.ShouldBe(expectedRecordClaim.Value); } } @@ -461,9 +459,8 @@ public async Task WhenIGetTheUserWithUserNameTheUserDetailsAreReturnedAsFollows( } foreach (KeyValuePair expectedRecordClaim in expectedRecord.Claims){ - var claimName = $"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/{expectedRecordClaim.Key}"; - userDetails.Data.Claims.ContainsKey(claimName).ShouldBeTrue(claimName); - String? claim = userDetails.Data.Claims[claimName]; + userDetails.Data.Claims.ContainsKey(expectedRecordClaim.Key).ShouldBeTrue(expectedRecordClaim.Key); + String? claim = userDetails.Data.Claims[expectedRecordClaim.Key]; claim.ShouldBe(expectedRecordClaim.Value); } // TODO: Add back in diff --git a/SecurityService.IntegrationTests/Common/DockerHelper.cs b/SecurityService.IntegrationTests/Common/DockerHelper.cs index 63f4964..5ca087b 100644 --- a/SecurityService.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.IntegrationTests/Common/DockerHelper.cs @@ -30,35 +30,38 @@ Object Deserialise(String arg, Type type) { return StringSerialiser.DeserializeObject(arg, type, new SerialiserOptions(SerialiserPropertyFormat.SnakeCase)); } + public String AccessToken; - //public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices, ) - //{ - // this._integrationTestCertificate = IntegrationTestCertificate.Create(); - - // try - // { - // await base.StartContainersForScenarioRun(scenarioName, dockerServices); - - // Func securityServiceBaseAddressResolver = _ => $"https://localhost:{this.SecurityServicePort}"; - // HttpClient httpClient = this.CreatePinnedHttpClient(); - // this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); - - // SimpleResults.Result bootstrapToken = await this.SecurityServiceClient.GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None); - // if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) - // { - // throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); - // } - // this.AccessToken = bootstrapToken.Data.AccessToken; - - // ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; - // } - // catch - // { - // this.DisposeIntegrationTestCertificate(); - // throw; - // } - //} + public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices, CancellationToken cancellationToken) + { + try + { + await base.StartContainersForScenarioRun(scenarioName, dockerServices, cancellationToken).ConfigureAwait(false); + + Func securityServiceBaseAddressResolver = _ => $"https://localhost:{this.SecurityServicePort}"; + HttpClient httpClient = this.CreatePinnedHttpClient(); + this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); + + SimpleResults.Result bootstrapToken = + await this.SecurityServiceClient + .GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None) + .ConfigureAwait(false); + + if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) + { + throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); + } + + this.AccessToken = bootstrapToken.Data.AccessToken; + ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + } + catch + { + this.DisposeIntegrationTestCertificate(); + throw; + } + } public override ContainerBuilder SetupSecurityServiceContainer() { @@ -130,21 +133,21 @@ public override Task CreateSubscriptions(CancellationToken cancellationToken) return Task.CompletedTask; } - //private HttpClient CreatePinnedHttpClient() - //{ - // if (this.IntegrationTestCertificate is null) - // { - // throw new InvalidOperationException("Integration test certificate was not initialised before HttpClient creation."); - // } - - // HttpClientHandler handler = new HttpClientHandler - // { - // ServerCertificateCustomValidationCallback = (_, certificate, _, _) => - // certificate is X509Certificate2 certificate2 && - // String.Equals(certificate2.Thumbprint, this.IntegrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) - // }; - - // return new HttpClient(handler, disposeHandler: true); - //} + private HttpClient CreatePinnedHttpClient() + { + if (this.IntegrationTestCertificate is null) + { + throw new InvalidOperationException("Integration test certificate was not initialised before HttpClient creation."); + } + + HttpClientHandler handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + certificate is X509Certificate2 certificate2 && + String.Equals(certificate2.Thumbprint, this.IntegrationTestCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase) + }; + + return new HttpClient(handler, disposeHandler: true); + } } } From adac72907f56a76b474f79003f02735db9e890bf Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 14:03:50 +0100 Subject: [PATCH 4/6] handle omitted user claims in integration tests --- .../SecurityServiceSteps.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index da8999e..95bd4a6 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -41,8 +41,13 @@ private static void AssertStringCollectionsMatch( private static void AssertUserClaimsContain( IReadOnlyDictionary actual, - IReadOnlyDictionary expected) + IReadOnlyDictionary? expected) { + if (expected is null) + { + return; + } + foreach (KeyValuePair expectedClaim in expected) { actual.ContainsKey(expectedClaim.Key).ShouldBeTrue(expectedClaim.Key); From b677ee7ed3a0949a95ae73c7a581c0c597a30884 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 14:14:43 +0100 Subject: [PATCH 5/6] normalize integration test expectations --- .../ReqnrollExtensions.cs | 4 +++ .../SecurityServiceSteps.cs | 25 ++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/SecurityService.IntegrationTesting.Helpers/ReqnrollExtensions.cs b/SecurityService.IntegrationTesting.Helpers/ReqnrollExtensions.cs index 8e26eda..fb0423f 100644 --- a/SecurityService.IntegrationTesting.Helpers/ReqnrollExtensions.cs +++ b/SecurityService.IntegrationTesting.Helpers/ReqnrollExtensions.cs @@ -316,6 +316,10 @@ public static List ToUserResponses(this DataTableRows tableRows){ private static List SplitString(this String stringToSplit){ List result = new List(); + if (String.IsNullOrWhiteSpace(stringToSplit)){ + return result; + } + List splitString = stringToSplit.Split(",").ToList(); foreach (String split in splitString){ result.Add(split.Trim()); diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index 95bd4a6..c463e34 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -7,6 +7,7 @@ namespace SecurityService.IntegrationTesting.Helpers; using Shared.IntegrationTesting; using Shouldly; using System.Collections.Generic; +using System.Security.Claims; using System.Threading; using Reqnroll; @@ -50,11 +51,21 @@ private static void AssertUserClaimsContain( foreach (KeyValuePair expectedClaim in expected) { - actual.ContainsKey(expectedClaim.Key).ShouldBeTrue(expectedClaim.Key); - actual[expectedClaim.Key].ShouldBe(expectedClaim.Value); + String claimType = GetClaimType(expectedClaim.Key); + actual.ContainsKey(claimType).ShouldBeTrue(expectedClaim.Key); + actual[claimType].ShouldBe(expectedClaim.Value); } } + private static String GetClaimType(String claimName) => claimName.ToLowerInvariant() switch + { + "emailaddress" => ClaimTypes.Email, + "givenname" => ClaimTypes.GivenName, + "surname" => ClaimTypes.Surname, + "role" => ClaimTypes.Role, + _ => claimName + }; + private async Task GetAndVerifyUser(CreateUserRequest request, CancellationToken cancellationToken) { Result>? response = await this.SecurityServiceClient @@ -443,8 +454,9 @@ public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(List expectedRecordClaim in expectedRecord.Claims){ - foundRecord.Claims.ContainsKey(expectedRecordClaim.Key).ShouldBeTrue(expectedRecordClaim.Key); - String? claim = foundRecord.Claims[expectedRecordClaim.Key]; + String claimType = GetClaimType(expectedRecordClaim.Key); + foundRecord.Claims.ContainsKey(claimType).ShouldBeTrue(expectedRecordClaim.Key); + String? claim = foundRecord.Claims[claimType]; claim.ShouldBe(expectedRecordClaim.Value); } } @@ -464,8 +476,9 @@ public async Task WhenIGetTheUserWithUserNameTheUserDetailsAreReturnedAsFollows( } foreach (KeyValuePair expectedRecordClaim in expectedRecord.Claims){ - userDetails.Data.Claims.ContainsKey(expectedRecordClaim.Key).ShouldBeTrue(expectedRecordClaim.Key); - String? claim = userDetails.Data.Claims[expectedRecordClaim.Key]; + String claimType = GetClaimType(expectedRecordClaim.Key); + userDetails.Data.Claims.ContainsKey(claimType).ShouldBeTrue(expectedRecordClaim.Key); + String? claim = userDetails.Data.Claims[claimType]; claim.ShouldBe(expectedRecordClaim.Value); } // TODO: Add back in From 848757f0592e3840f73a37921ac7bbecc69dcd97 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 12 Oct 2026 14:24:42 +0100 Subject: [PATCH 6/6] ignore bootstrap client in client scenario --- .../SecurityServiceSteps.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index c463e34..19f222e 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -309,7 +309,10 @@ public async Task WhenIGetTheClientsClientsDetailsAreReturnedAsFollows(List>? clientDetailsListResult = await this.SecurityServiceClient.GetClients(this.AccessToken, cancellationToken).ConfigureAwait(false); clientDetailsListResult.IsSuccess.ShouldBeTrue(); List? clientDetailsList = clientDetailsListResult.Data; - AssertExpectedResults(clientDetailsList!, expectedDetails, client => client.ClientId); + List expectedClientDetails = clientDetailsList! + .Where(client => expectedDetails.Any(expected => expected.ClientId == client.ClientId)) + .ToList(); + AssertExpectedResults(expectedClientDetails, expectedDetails, client => client.ClientId); foreach (ClientResponse expectedRecord in expectedDetails){ ClientResponse? foundRecord = clientDetailsList.SingleOrDefault(a => a.ClientId == expectedRecord.ClientId); foundRecord.ShouldNotBeNull();