From 5baeb9a4804626489d6cd56aa004a7fd46200008 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:11:05 +0100 Subject: [PATCH 1/5] SdkAcceptanceTests -> PASS --- .../Clients/ApiPlatforms/FakeSession.cs | 32 ++++ ...ssionBackedApiPlatformClientTests.Login.cs | 104 ++++++++++ ...edApiPlatformClientTests.SearchPatients.cs | 88 +++++++++ .../SessionBackedApiPlatformClientTests.cs | 179 ++++++++++++++++++ .../ApiPlatformClientTests.Cancellations.cs | 72 +++++++ .../ApiPlatformClientTests.Exceptions.cs | 81 ++++++++ .../ApiPlatformClientTests.Login.cs | 96 ++++++++++ .../ApiPlatformClientTests.SearchPatients.cs | 74 ++++++++ .../ApiPlatformClientTests.Validations.cs | 55 ++++++ .../ApiPlatforms/ApiPlatformClientTests.cs | 176 +++++++++++++++++ 10 files changed, 957 insertions(+) create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/FakeSession.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Login.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Login.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Validations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/FakeSession.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/FakeSession.cs new file mode 100644 index 0000000..7737ea0 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/FakeSession.cs @@ -0,0 +1,32 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + internal sealed class FakeSession : ISession + { + private readonly Dictionary store = new Dictionary(); + + public bool IsAvailable => true; + public string Id => "acceptance-session"; + public IEnumerable Keys => this.store.Keys; + + public void Clear() => this.store.Clear(); + + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Remove(string key) => this.store.Remove(key); + + public void Set(string key, byte[] value) => this.store[key] = value; + + public bool TryGetValue(string key, out byte[] value) => this.store.TryGetValue(key, out value); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Login.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Login.cs new file mode 100644 index 0000000..997184a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Login.cs @@ -0,0 +1,104 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class SessionBackedApiPlatformClientTests + { + [Fact] + public async Task ShouldPersistCsrfStateInTheSessionOnBuildLoginUrlAsync() + { + // given + // when + string actualLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + string state = ExtractStateFromLoginUrl(actualLoginUrl); + state.Should().NotBeNullOrWhiteSpace(); + this.fakeSession.Keys.Should().Contain("Nhs.ApiPlatform.CsrfState"); + } + + [Fact] + public async Task ShouldPersistTokensInTheSessionOnCompletingTheLoginFlowAsync() + { + // given + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // then + this.fakeSession.Keys.Should().Contain("Nhs.ApiPlatform.AccessToken"); + this.fakeSession.Keys.Should().Contain("Nhs.ApiPlatform.RefreshToken"); + this.fakeSession.Keys.Should().Contain("Nhs.ApiPlatform.ActiveRoleId"); + } + + [Fact] + public async Task ShouldReturnUserInfoOnCompletingTheLoginFlowAsync() + { + // given + string randomUserUid = GetRandomString(); + string randomRoleId = GetRandomString(); + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(randomUserUid, randomRoleId); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // then + actualUserInfo.NhsIdUserUid.Should().Be(randomUserUid); + actualUserInfo.NhsIdNrbacRoles.Single().PersonRoleId.Should().Be(randomRoleId); + } + + [Fact] + public async Task ShouldReturnSessionStoredAccessTokenOnGetAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + GivenTokenEndpointReturns(randomAccessToken, GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldRemoveTokensFromTheSessionOnLogoutAsync() + { + // given + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // when + await this.careIdentityServiceClient.LogoutAsync(); + + // then + this.fakeSession.Keys.Should().NotContain("Nhs.ApiPlatform.AccessToken"); + this.fakeSession.Keys.Should().NotContain("Nhs.ApiPlatform.RefreshToken"); + this.fakeSession.Keys.Should().NotContain("Nhs.ApiPlatform.ActiveRoleId"); + this.fakeSession.Keys.Should().NotContain("Nhs.ApiPlatform.CsrfState"); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.SearchPatients.cs new file mode 100644 index 0000000..473b636 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.SearchPatients.cs @@ -0,0 +1,88 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class SessionBackedApiPlatformClientTests + { + [Fact] + public async Task ShouldSearchPatientsUsingTheSessionStoredCredentialsAsync() + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + string randomAccessToken = GetRandomString(); + string randomRoleId = GetRandomString(); + string randomPatientPayload = $"{{\"resourceType\":\"Patient\",\"id\":\"{randomNhsNumber}\"}}"; + await GivenAnAuthenticatedSessionAsync(randomAccessToken, randomRoleId); + GivenPatientEndpointReturns(randomNhsNumber, randomPatientPayload); + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + string actualPayload = + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria); + + // then + actualPayload.Should().Be(randomPatientPayload); + + var patientRequest = this.wireMockServer.LogEntries + .Last(entry => entry.RequestMessage.Path.EndsWith($"/Patient/{randomNhsNumber}")); + + patientRequest.RequestMessage.Headers["Authorization"] + .Should().Contain($"Bearer {randomAccessToken}"); + + patientRequest.RequestMessage.Headers["NHSD-Session-URID"] + .Should().Contain(randomRoleId); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfTheSessionIsNotAuthenticatedAsync() + { + // given + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(GetRandomNhsNumber()); + + // when + PersonalDemographicsServiceClientValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + + // then + actualException.InnerException.Message + .Should().Be("Unauthorized - Unable to retrieve access token."); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + { + // given + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(GetRandomNhsNumber()); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + // then + await Assert.ThrowsAnyAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync( + searchCriteria, + cancellationTokenSource.Token)); + } + + private async Task GivenAnAuthenticatedSessionAsync(string accessToken, string roleId) + { + GivenTokenEndpointReturns(accessToken, GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), roleId); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs new file mode 100644 index 0000000..5a33d8d --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs @@ -0,0 +1,179 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; +using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Clients.PersonalDemographicsServices; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Tynamix.ObjectFiller; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class SessionBackedApiPlatformClientTests : IDisposable + { + private const string TokenPath = "/oauth2/token"; + private const string UserInfoPath = "/oauth2/userinfo"; + private const string AuthorizePath = "/oauth2/authorize"; + private const string FhirPath = "/personal-demographics/FHIR/R4"; + + private readonly WireMockServer wireMockServer; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly ServiceProvider serviceProvider; + private readonly IServiceScope serviceScope; + private readonly FakeSession fakeSession; + private readonly ICareIdentityServiceClient careIdentityServiceClient; + private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; + + public SessionBackedApiPlatformClientTests() + { + this.wireMockServer = WireMockServer.Start(); + string baseUrl = this.wireMockServer.Urls[0]; + this.fakeSession = new FakeSession(); + + this.apiPlatformConfigurations = new ApiPlatformConfigurations + { + CareIdentity = new CareIdentityConfigurations + { + ClientId = GetRandomString(), + ClientSecret = GetRandomString(), + RedirectUri = "https://localhost:5174/auth/callback", + AuthEndpoint = $"{baseUrl}{AuthorizePath}", + TokenEndpoint = $"{baseUrl}{TokenPath}", + UserInfoEndpoint = $"{baseUrl}{UserInfoPath}" + }, + + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"{baseUrl}{FhirPath}" + } + }; + + var httpContext = new DefaultHttpContext + { + Session = this.fakeSession + }; + + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton( + new HttpContextAccessor { HttpContext = httpContext }); + + services.AddApiPlatformSdkCore(this.apiPlatformConfigurations); + services.AddApiPlatformSdkAspNetCore(); + this.serviceProvider = services.BuildServiceProvider(); + this.serviceScope = this.serviceProvider.CreateScope(); + + IApiPlatformClient apiPlatformClient = + this.serviceScope.ServiceProvider.GetRequiredService(); + + this.careIdentityServiceClient = apiPlatformClient.CareIdentityServiceClient; + this.personalDemographicsServiceClient = apiPlatformClient.PersonalDemographicsServiceClient; + } + + private void GivenTokenEndpointReturns(string accessToken, string refreshToken) + { + var tokenPayload = new Dictionary + { + ["access_token"] = accessToken, + ["token_type"] = "Bearer", + ["expires_in"] = "3600", + ["refresh_token"] = refreshToken, + ["refresh_token_expires_in"] = "7200" + }; + + this.wireMockServer + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(tokenPayload))); + } + + private void GivenUserInfoEndpointReturns(string userUid, string roleId) + { + string userInfoJson = JsonSerializer.Serialize(new + { + nhsid_useruid = userUid, + name = GetRandomString(), + sub = GetRandomString(), + nhsid_nrbac_roles = new[] + { + new + { + person_orgid = GetRandomString(), + person_roleid = roleId, + org_code = GetRandomString(), + role_name = GetRandomString(), + role_code = GetRandomString() + } + } + }); + + this.wireMockServer + .Given(Request.Create().WithPath(UserInfoPath).UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody(userInfoJson)); + } + + private void GivenPatientEndpointReturns(string nhsNumber, string body) + { + this.wireMockServer + .Given(Request.Create().WithPath($"{FhirPath}/Patient/{nhsNumber}").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/fhir+json") + .WithBody(body)); + } + + private static string ExtractStateFromLoginUrl(string loginUrl) + { + string query = new Uri(loginUrl).Query.TrimStart('?'); + + foreach (string pair in query.Split('&')) + { + string[] parts = pair.Split('='); + + if (parts.Length == 2 && parts[0] == "state") + { + return parts[1]; + } + } + + return string.Empty; + } + + private static SearchCriteria CreateSearchCriteriaByNhsNumber(string nhsNumber) => + new SearchCriteria + { + NhsNumber = nhsNumber + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static string GetRandomNhsNumber() => + new IntRange(min: 100000000, max: 999999999).GetValue().ToString(); + + public void Dispose() + { + this.serviceScope.Dispose(); + this.serviceProvider.Dispose(); + this.wireMockServer.Stop(); + this.wireMockServer.Dispose(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs new file mode 100644 index 0000000..0db20ae --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs @@ -0,0 +1,72 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class ApiPlatformClientTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + // then + await Assert.ThrowsAnyAsync(async () => + await this.careIdentityServiceClient.BuildLoginUrlAsync(cancellationTokenSource.Token)); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + { + // given + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(GetRandomNhsNumber()); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + // then + await Assert.ThrowsAnyAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync( + searchCriteria, + cancellationTokenSource.Token)); + } + + [Fact] + public async Task ShouldNotWrapCancellationWhenTheDependencyIsStillRespondingOnSearchPatientsAsync() + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + await GivenAnAuthenticatedSessionAsync(); + + this.wireMockServer + .Given(Request.Create().WithPath($"{FhirPath}/Patient/{randomNhsNumber}").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithDelay(TimeSpan.FromSeconds(30)) + .WithBody("{}")); + + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + + // when + // then + await Assert.ThrowsAnyAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync( + searchCriteria, + cancellationTokenSource.Token)); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs new file mode 100644 index 0000000..38caff3 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs @@ -0,0 +1,81 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class ApiPlatformClientTests + { + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTokenEndpointFailsAsync( + HttpStatusCode statusCode) + { + // given + GivenTokenEndpointFailsWith(statusCode); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfStateDoesNotMatchAsync() + { + // given + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string tamperedState = GetRandomString(); + + // when + CareIdentityServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync( + GetRandomString(), + tamperedState)); + + // then + actualException.InnerException.Message.Should().Be("Invalid state parameter."); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfPdsFailsAsync( + HttpStatusCode statusCode) + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + await GivenAnAuthenticatedSessionAsync(); + GivenPatientEndpointFailsWith(randomNhsNumber, statusCode); + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Login.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Login.cs new file mode 100644 index 0000000..c686d68 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Login.cs @@ -0,0 +1,96 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class ApiPlatformClientTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + // when + string actualLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().StartWith(this.apiPlatformConfigurations.CareIdentity.AuthEndpoint); + actualLoginUrl.Should().Contain($"client_id={this.apiPlatformConfigurations.CareIdentity.ClientId}"); + actualLoginUrl.Should().Contain("response_type=code"); + ExtractStateFromLoginUrl(actualLoginUrl).Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task ShouldReturnUserInfoOnCompletingTheLoginFlowAsync() + { + // given + string randomUserUid = GetRandomString(); + string randomRoleId = GetRandomString(); + GivenTokenEndpointReturns(accessToken: GetRandomString(), refreshToken: GetRandomString()); + GivenUserInfoEndpointReturns(randomUserUid, randomRoleId); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // then + actualUserInfo.NhsIdUserUid.Should().Be(randomUserUid); + actualUserInfo.NhsIdNrbacRoles.Should().ContainSingle(); + actualUserInfo.NhsIdNrbacRoles[0].PersonRoleId.Should().Be(randomRoleId); + } + + [Fact] + public async Task ShouldReturnAccessTokenAfterCompletingTheLoginFlowAsync() + { + // given + string randomAccessToken = GetRandomString(); + GivenTokenEndpointReturns(randomAccessToken, refreshToken: GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldReturnEmptyAccessTokenBeforeLoggingInAsync() + { + // given + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldDiscardAccessTokenOnLogoutAsync() + { + // given + GivenTokenEndpointReturns(GetRandomString(), refreshToken: GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + + // when + await this.careIdentityServiceClient.LogoutAsync(); + + // then + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + actualAccessToken.Should().BeEmpty(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.SearchPatients.cs new file mode 100644 index 0000000..578645a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.SearchPatients.cs @@ -0,0 +1,74 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class ApiPlatformClientTests + { + [Fact] + public async Task ShouldSearchPatientsByNhsNumberAsync() + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + string randomPatientPayload = $"{{\"resourceType\":\"Patient\",\"id\":\"{randomNhsNumber}\"}}"; + await GivenAnAuthenticatedSessionAsync(); + GivenPatientEndpointReturns(randomNhsNumber, randomPatientPayload); + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + string actualPayload = + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria); + + // then + actualPayload.Should().Be(randomPatientPayload); + } + + [Fact] + public async Task ShouldSendAuthorisationAndSessionHeadersOnSearchPatientsAsync() + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + string randomAccessToken = GetRandomString(); + string randomRoleId = GetRandomString(); + await GivenAnAuthenticatedSessionAsync(randomAccessToken, randomRoleId); + GivenPatientEndpointReturns(randomNhsNumber, "{}"); + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria); + + // then + var patientRequest = this.wireMockServer.LogEntries + .Last(entry => entry.RequestMessage.Path.EndsWith($"/Patient/{randomNhsNumber}")); + + patientRequest.RequestMessage.Headers["Authorization"] + .Should().Contain($"Bearer {randomAccessToken}"); + + patientRequest.RequestMessage.Headers["NHSD-Session-URID"] + .Should().Contain(randomRoleId); + + patientRequest.RequestMessage.Headers.Should().ContainKey("X-Request-ID"); + } + + private async Task GivenAnAuthenticatedSessionAsync( + string accessToken = null, + string roleId = null) + { + GivenTokenEndpointReturns( + accessToken: accessToken ?? GetRandomString(), + refreshToken: GetRandomString()); + + GivenUserInfoEndpointReturns(GetRandomString(), roleId ?? GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Validations.cs new file mode 100644 index 0000000..dd2ec5f --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Validations.cs @@ -0,0 +1,55 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class ApiPlatformClientTests + { + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + SearchCriteria nullSearchCriteria = null; + + // when + // then + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(nullSearchCriteria)); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsEmptyAsync() + { + // given + var emptySearchCriteria = new SearchCriteria(); + + // when + // then + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(emptySearchCriteria)); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNotAuthenticatedAsync() + { + // given + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(GetRandomNhsNumber()); + + // when + PersonalDemographicsServiceClientValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + + // then + actualException.InnerException.Message + .Should().Be("Unauthorized - Unable to retrieve access token."); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs new file mode 100644 index 0000000..ba504f7 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs @@ -0,0 +1,176 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; +using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Clients.PersonalDemographicsServices; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Tynamix.ObjectFiller; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Acceptance.Clients.ApiPlatforms +{ + [Collection(nameof(ApiPlatformClientTests))] + public partial class ApiPlatformClientTests : IDisposable + { + private const string TokenPath = "/oauth2/token"; + private const string UserInfoPath = "/oauth2/userinfo"; + private const string AuthorizePath = "/oauth2/authorize"; + private const string FhirPath = "/personal-demographics/FHIR/R4"; + + private readonly WireMockServer wireMockServer; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly IApiPlatformClient apiPlatformClient; + private readonly ICareIdentityServiceClient careIdentityServiceClient; + private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; + + public ApiPlatformClientTests() + { + this.wireMockServer = WireMockServer.Start(); + string baseUrl = this.wireMockServer.Urls[0]; + + this.apiPlatformConfigurations = new ApiPlatformConfigurations + { + CareIdentity = new CareIdentityConfigurations + { + ClientId = GetRandomString(), + ClientSecret = GetRandomString(), + RedirectUri = "https://localhost:5174/auth/callback", + AuthEndpoint = $"{baseUrl}{AuthorizePath}", + TokenEndpoint = $"{baseUrl}{TokenPath}", + UserInfoEndpoint = $"{baseUrl}{UserInfoPath}" + }, + + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"{baseUrl}{FhirPath}" + } + }; + + this.apiPlatformClient = new ApiPlatformClient(this.apiPlatformConfigurations); + this.careIdentityServiceClient = this.apiPlatformClient.CareIdentityServiceClient; + + this.personalDemographicsServiceClient = + this.apiPlatformClient.PersonalDemographicsServiceClient; + } + + private void GivenTokenEndpointReturns( + string accessToken, + string refreshToken, + int expiresInSeconds = 3600) + { + var tokenPayload = new Dictionary + { + ["access_token"] = accessToken, + ["token_type"] = "Bearer", + ["expires_in"] = expiresInSeconds.ToString(), + ["refresh_token"] = refreshToken, + ["refresh_token_expires_in"] = (expiresInSeconds * 2).ToString() + }; + + this.wireMockServer + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody(JsonSerializer.Serialize(tokenPayload))); + } + + private void GivenTokenEndpointFailsWith(HttpStatusCode statusCode) + { + this.wireMockServer + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create().WithStatusCode(statusCode)); + } + + private void GivenUserInfoEndpointReturns(string userUid, string roleId) + { + string userInfoJson = JsonSerializer.Serialize(new + { + nhsid_useruid = userUid, + name = GetRandomString(), + sub = GetRandomString(), + nhsid_nrbac_roles = new[] + { + new + { + person_orgid = GetRandomString(), + person_roleid = roleId, + org_code = GetRandomString(), + role_name = GetRandomString(), + role_code = GetRandomString() + } + } + }); + + this.wireMockServer + .Given(Request.Create().WithPath(UserInfoPath).UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody(userInfoJson)); + } + + private void GivenPatientEndpointReturns(string nhsNumber, string body) + { + this.wireMockServer + .Given(Request.Create().WithPath($"{FhirPath}/Patient/{nhsNumber}").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/fhir+json") + .WithBody(body)); + } + + private void GivenPatientEndpointFailsWith(string nhsNumber, HttpStatusCode statusCode) + { + this.wireMockServer + .Given(Request.Create().WithPath($"{FhirPath}/Patient/{nhsNumber}").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(statusCode)); + } + + private static string ExtractStateFromLoginUrl(string loginUrl) + { + var uri = new Uri(loginUrl); + string query = uri.Query.TrimStart('?'); + + foreach (string pair in query.Split('&')) + { + string[] parts = pair.Split('='); + + if (parts.Length == 2 && parts[0] == "state") + { + return parts[1]; + } + } + + return string.Empty; + } + + private static SearchCriteria CreateSearchCriteriaByNhsNumber(string nhsNumber) => + new SearchCriteria + { + NhsNumber = nhsNumber + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static string GetRandomNhsNumber() => + new IntRange(min: 100000000, max: 999999999).GetValue().ToString(); + + public void Dispose() + { + this.wireMockServer.Stop(); + this.wireMockServer.Dispose(); + } + } +} From f1696923a06b589b1fb94bcb0e3b6520f33b5ee4 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:25:56 +0100 Subject: [PATCH 2/5] SdkAcceptanceTimeoutTests -> FAIL --- ...onBackedApiPlatformClientTests.Timeouts.cs | 71 +++++++++++++++++++ .../SessionBackedApiPlatformClientTests.cs | 5 ++ 2 files changed, 76 insertions(+) create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs new file mode 100644 index 0000000..832cb9f --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs @@ -0,0 +1,71 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + public partial class SessionBackedApiPlatformClientTests + { + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTheTokenEndpointTimesOutAsync() + { + // given + this.wireMockServer + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithDelay(TimeSpan.FromSeconds(10)) + .WithBody("{}")); + + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + + actualException.InnerException.InnerException.Message + .Should().Be("The dependency operation timed out."); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfThePatientEndpointTimesOutAsync() + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + await GivenAnAuthenticatedSessionAsync(GetRandomString(), GetRandomString()); + + this.wireMockServer + .Given(Request.Create().WithPath($"{FhirPath}/Patient/{randomNhsNumber}").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithDelay(TimeSpan.FromSeconds(10)) + .WithBody("{}")); + + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs index 5a33d8d..0a9b1e2 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs @@ -72,6 +72,11 @@ public SessionBackedApiPlatformClientTests() services.AddApiPlatformSdkCore(this.apiPlatformConfigurations); services.AddApiPlatformSdkAspNetCore(); + + // Keep the dependency timeout short so the timeout path is observable in a test run. + services.AddHttpClient("NhsApiPlatform") + .ConfigureHttpClient(httpClient => httpClient.Timeout = TimeSpan.FromMilliseconds(500)); + this.serviceProvider = services.BuildServiceProvider(); this.serviceScope = this.serviceProvider.CreateScope(); From fdbdd44d36194127cffec6e1d95fa7aa6528dfa3 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:28:02 +0100 Subject: [PATCH 3/5] SdkAcceptanceTimeoutTests -> PASS From f0e30ae147394ffbc03eea5ae5840dbe0520c02a Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 17:31:01 +0100 Subject: [PATCH 4/5] SdkAcceptanceReviewRemediationTests -> PASS --- ...onBackedApiPlatformClientTests.Timeouts.cs | 37 ++++++++++++++++--- .../SessionBackedApiPlatformClientTests.cs | 35 +++++++++++------- .../ApiPlatformClientTests.Cancellations.cs | 4 +- .../ApiPlatformClientTests.Exceptions.cs | 28 ++++++++++++-- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs index 832cb9f..7d0fc5e 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs @@ -6,6 +6,8 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; @@ -17,6 +19,10 @@ namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlat { public partial class SessionBackedApiPlatformClientTests { + // Only these two tests need a short dependency timeout. Applying it to the whole class + // would leave every other test one slow HTTP call away from failing as a timeout. + private static readonly TimeSpan ShortDependencyTimeout = TimeSpan.FromSeconds(1); + [Fact] public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTheTokenEndpointTimesOutAsync() { @@ -25,16 +31,24 @@ public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTheTokenEndpointT .Given(Request.Create().WithPath(TokenPath).UsingPost()) .RespondWith(Response.Create() .WithStatusCode(HttpStatusCode.OK) - .WithDelay(TimeSpan.FromSeconds(10)) + .WithDelay(TimeSpan.FromSeconds(5)) .WithBody("{}")); - string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + using ServiceProvider timeoutProvider = BuildServiceProvider(ShortDependencyTimeout); + using IServiceScope timeoutScope = timeoutProvider.CreateScope(); + + IApiPlatformClient timeoutClient = + timeoutScope.ServiceProvider.GetRequiredService(); + + string loginUrl = await timeoutClient.CareIdentityServiceClient.BuildLoginUrlAsync(); string state = ExtractStateFromLoginUrl(loginUrl); // when CareIdentityServiceClientDependencyException actualException = await Assert.ThrowsAsync(async () => - await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state)); + await timeoutClient.CareIdentityServiceClient.GetUserInfoAsync( + GetRandomString(), + state)); // then actualException.InnerException.InnerException.Should().BeOfType(); @@ -48,13 +62,23 @@ public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfThePatientEndp { // given string randomNhsNumber = GetRandomNhsNumber(); - await GivenAnAuthenticatedSessionAsync(GetRandomString(), GetRandomString()); + using ServiceProvider timeoutProvider = BuildServiceProvider(ShortDependencyTimeout); + using IServiceScope timeoutScope = timeoutProvider.CreateScope(); + + IApiPlatformClient timeoutClient = + timeoutScope.ServiceProvider.GetRequiredService(); + + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await timeoutClient.CareIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + await timeoutClient.CareIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); this.wireMockServer .Given(Request.Create().WithPath($"{FhirPath}/Patient/{randomNhsNumber}").UsingGet()) .RespondWith(Response.Create() .WithStatusCode(HttpStatusCode.OK) - .WithDelay(TimeSpan.FromSeconds(10)) + .WithDelay(TimeSpan.FromSeconds(5)) .WithBody("{}")); SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); @@ -62,7 +86,8 @@ public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfThePatientEndp // when PersonalDemographicsServiceClientDependencyException actualException = await Assert.ThrowsAsync(async () => - await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + await timeoutClient.PersonalDemographicsServiceClient.SearchPatientsAsync( + searchCriteria)); // then actualException.InnerException.InnerException.Should().BeOfType(); diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs index 0a9b1e2..e89a793 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -33,6 +33,7 @@ public partial class SessionBackedApiPlatformClientTests : IDisposable private readonly ServiceProvider serviceProvider; private readonly IServiceScope serviceScope; private readonly FakeSession fakeSession; + private readonly DefaultHttpContext httpContext; private readonly ICareIdentityServiceClient careIdentityServiceClient; private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; @@ -65,26 +66,34 @@ public SessionBackedApiPlatformClientTests() Session = this.fakeSession }; + this.httpContext = httpContext; + this.serviceProvider = BuildServiceProvider(httpClientTimeout: null); + this.serviceScope = this.serviceProvider.CreateScope(); + + IApiPlatformClient apiPlatformClient = + this.serviceScope.ServiceProvider.GetRequiredService(); + + this.careIdentityServiceClient = apiPlatformClient.CareIdentityServiceClient; + this.personalDemographicsServiceClient = apiPlatformClient.PersonalDemographicsServiceClient; + } + + private ServiceProvider BuildServiceProvider(TimeSpan? httpClientTimeout) + { IServiceCollection services = new ServiceCollection(); services.AddSingleton( - new HttpContextAccessor { HttpContext = httpContext }); + new HttpContextAccessor { HttpContext = this.httpContext }); services.AddApiPlatformSdkCore(this.apiPlatformConfigurations); services.AddApiPlatformSdkAspNetCore(); - // Keep the dependency timeout short so the timeout path is observable in a test run. - services.AddHttpClient("NhsApiPlatform") - .ConfigureHttpClient(httpClient => httpClient.Timeout = TimeSpan.FromMilliseconds(500)); - - this.serviceProvider = services.BuildServiceProvider(); - this.serviceScope = this.serviceProvider.CreateScope(); - - IApiPlatformClient apiPlatformClient = - this.serviceScope.ServiceProvider.GetRequiredService(); + if (httpClientTimeout is not null) + { + services.AddHttpClient("NhsApiPlatform") + .ConfigureHttpClient(httpClient => httpClient.Timeout = httpClientTimeout.Value); + } - this.careIdentityServiceClient = apiPlatformClient.CareIdentityServiceClient; - this.personalDemographicsServiceClient = apiPlatformClient.PersonalDemographicsServiceClient; + return services.BuildServiceProvider(); } private void GivenTokenEndpointReturns(string accessToken, string refreshToken) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs index 0db20ae..5a6f2bb 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -55,7 +55,7 @@ public async Task ShouldNotWrapCancellationWhenTheDependencyIsStillRespondingOnS .Given(Request.Create().WithPath($"{FhirPath}/Patient/{randomNhsNumber}").UsingGet()) .RespondWith(Response.Create() .WithStatusCode(HttpStatusCode.OK) - .WithDelay(TimeSpan.FromSeconds(30)) + .WithDelay(TimeSpan.FromSeconds(2)) .WithBody("{}")); SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs index 38caff3..14acd36 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -38,7 +38,7 @@ await Assert.ThrowsAsync(async () } [Fact] - public async Task ShouldThrowValidationExceptionOnGetUserInfoIfStateDoesNotMatchAsync() + public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfStateDoesNotMatchAsync() { // given GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); @@ -59,7 +59,7 @@ await this.careIdentityServiceClient.GetUserInfoAsync( [Theory] [InlineData(HttpStatusCode.InternalServerError)] - [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadGateway)] public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfPdsFailsAsync( HttpStatusCode statusCode) { @@ -77,5 +77,27 @@ await Assert.ThrowsAsync(a // then actualException.InnerException.InnerException.Should().BeOfType(); } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Unauthorized)] + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfPdsRejectsTheRequestAsync( + HttpStatusCode statusCode) + { + // given + string randomNhsNumber = GetRandomNhsNumber(); + await GivenAnAuthenticatedSessionAsync(); + GivenPatientEndpointFailsWith(randomNhsNumber, statusCode); + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + PersonalDemographicsServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } } } From c43fe70d34192cc950ec3af69ae8f60f1da4511a Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 18:08:52 +0100 Subject: [PATCH 5/5] SdkAcceptanceVerificationRemediationTests -> PASS --- ...onBackedApiPlatformClientTests.Timeouts.cs | 10 ++++----- .../SessionBackedApiPlatformClientTests.cs | 2 +- .../ApiPlatformClientTests.Exceptions.cs | 21 +++++++++++++++++++ .../ApiPlatforms/ApiPlatformClientTests.cs | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs index 7d0fc5e..bf56354 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -31,7 +31,7 @@ public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTheTokenEndpointT .Given(Request.Create().WithPath(TokenPath).UsingPost()) .RespondWith(Response.Create() .WithStatusCode(HttpStatusCode.OK) - .WithDelay(TimeSpan.FromSeconds(5)) + .WithDelay(TimeSpan.FromSeconds(3)) .WithBody("{}")); using ServiceProvider timeoutProvider = BuildServiceProvider(ShortDependencyTimeout); @@ -70,15 +70,15 @@ public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfThePatientEndp GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); - string loginUrl = await timeoutClient.CareIdentityServiceClient.BuildLoginUrlAsync(); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); string state = ExtractStateFromLoginUrl(loginUrl); - await timeoutClient.CareIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state); this.wireMockServer .Given(Request.Create().WithPath($"{FhirPath}/Patient/{randomNhsNumber}").UsingGet()) .RespondWith(Response.Create() .WithStatusCode(HttpStatusCode.OK) - .WithDelay(TimeSpan.FromSeconds(5)) + .WithDelay(TimeSpan.FromSeconds(3)) .WithBody("{}")); SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs index e89a793..aec78a9 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs @@ -180,7 +180,7 @@ private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); private static string GetRandomNhsNumber() => - new IntRange(min: 100000000, max: 999999999).GetValue().ToString(); + new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString(); public void Dispose() { diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs index 14acd36..43cc5c3 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs @@ -99,5 +99,26 @@ await Assert.ThrowsAsync(); } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfTheTokenEndpointRejectsUsAsync( + HttpStatusCode statusCode) + { + // given + GivenTokenEndpointFailsWith(statusCode); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + + // when + CareIdentityServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(GetRandomString(), state)); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs index ba504f7..9a6657b 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -165,7 +165,7 @@ private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); private static string GetRandomNhsNumber() => - new IntRange(min: 100000000, max: 999999999).GetValue().ToString(); + new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString(); public void Dispose() {