diff --git a/.gitignore b/.gitignore index e8fac3f..cf074b1 100644 --- a/.gitignore +++ b/.gitignore @@ -421,3 +421,9 @@ FodyWeavers.xsd # Sample/example projects - not part of the SDK ReactApp1.Server/ reactapp1.client/ + +# Integration/acceptance test local credentials +NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.Development.json +NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/appsettings.Development.json +NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/appsettings.Development.json +NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/appsettings.Development.json diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md index d738540..788fd0d 100644 --- a/Documentation/DependencyGraph/README.md +++ b/Documentation/DependencyGraph/README.md @@ -80,12 +80,18 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions). services split `HttpRequestException`: a 4xx becomes a `*DependencyValidationException` (the caller sent something the dependency rejected), a 5xx or a transport failure becomes a `*DependencyException`. -- **The storage brokers are the extension seam.** `IApiPlatformStateBroker` - and `IApiPlatformTokenBroker` each have an in-memory implementation in the - Sdk and a session-backed one in Sdk.AspNetCore. Both are registered with - `TryAdd`, so whichever the host registers first wins — call - `AddApiPlatformSdkAspNetCore()` before `AddApiPlatformSdkInMemoryStorage()` - in a web host, or you get the process-wide singletons. +- **The storage brokers are the extension seam, and order does not matter.** + `IApiPlatformStateBroker` and `IApiPlatformTokenBroker` each have an + in-memory implementation in the Sdk and a session-backed one in + Sdk.AspNetCore. `AddApiPlatformSdkInMemoryStorage` uses `TryAddSingleton`, + but `AddApiPlatformSdkAspNetCore` uses plain `AddScoped` — which appends + rather than no-ops, and the last registration wins. So calling both in + either order leaves a web host on the session-backed brokers. One caveat: + last-wins governs `GetService`/`GetRequiredService` only. If + `AddApiPlatformSdkInMemoryStorage` ran first its singleton descriptor is + still in the collection, so `GetServices()` returns + both — a host that enumerates implementations can still reach the + process-wide singleton. - **The in-memory brokers are singletons and hold one user's state.** Fine for a console app or a test; wrong for a multi-user web host. - **CIS2 runs without PKCE** — the code says so explicitly; only `client_id`, diff --git a/Documentation/DependencyGraph/graph-data.js b/Documentation/DependencyGraph/graph-data.js index ec5b602..dd96602 100644 --- a/Documentation/DependencyGraph/graph-data.js +++ b/Documentation/DependencyGraph/graph-data.js @@ -214,11 +214,11 @@ ------------------------------------------------------------------ */ C({ id: "StateBroker", name: "IApiPlatformStateBroker", project: "sdk", layer: "broker", col: 5, methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"], - description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd, so a host that has already registered the session one keeps it." }); + description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd; AddApiPlatformSdkAspNetCore registers the session one with AddScoped, which appends and therefore wins whichever order the two are called in." }); C({ id: "TokenBroker", name: "IApiPlatformTokenBroker", project: "sdk", layer: "broker", col: 5, methods: ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync", "StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"], - description: "Holds the access and refresh tokens with their expiry instants. Same TryAdd registration story as the state broker." }); + description: "Holds the access and refresh tokens with their expiry instants. Same registration story as the state broker - the session implementation wins in an ASP.NET Core host regardless of call order." }); C({ id: "MemoryStateBroker", name: "MemoryApiPlatformStateBroker", project: "sdk", layer: "broker", col: 6, methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"], 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.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs new file mode 100644 index 0000000..bf56354 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.Timeouts.cs @@ -0,0 +1,96 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +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; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance.Clients.ApiPlatforms +{ + 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() + { + // given + this.wireMockServer + .Given(Request.Create().WithPath(TokenPath).UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithDelay(TimeSpan.FromSeconds(3)) + .WithBody("{}")); + + 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 timeoutClient.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(); + using ServiceProvider timeoutProvider = BuildServiceProvider(ShortDependencyTimeout); + using IServiceScope timeoutScope = timeoutProvider.CreateScope(); + + IApiPlatformClient timeoutClient = + timeoutScope.ServiceProvider.GetRequiredService(); + + GivenTokenEndpointReturns(GetRandomString(), GetRandomString()); + GivenUserInfoEndpointReturns(GetRandomString(), GetRandomString()); + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractStateFromLoginUrl(loginUrl); + 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(3)) + .WithBody("{}")); + + SearchCriteria searchCriteria = CreateSearchCriteriaByNhsNumber(randomNhsNumber); + + // when + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync(async () => + 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 new file mode 100644 index 0000000..aec78a9 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/Clients/ApiPlatforms/SessionBackedApiPlatformClientTests.cs @@ -0,0 +1,193 @@ +// --------------------------------------------------------- +// 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 DefaultHttpContext httpContext; + 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 + }; + + 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 = this.httpContext }); + + services.AddApiPlatformSdkCore(this.apiPlatformConfigurations); + services.AddApiPlatformSdkAspNetCore(); + + if (httpClientTimeout is not null) + { + services.AddHttpClient("NhsApiPlatform") + .ConfigureHttpClient(httpClient => httpClient.Timeout = httpClientTimeout.Value); + } + + return services.BuildServiceProvider(); + } + + 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: 1000000000, max: 1999999999).GetValue().ToString(); + + public void Dispose() + { + this.serviceScope.Dispose(); + this.serviceProvider.Dispose(); + this.wireMockServer.Stop(); + this.wireMockServer.Dispose(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ConfigurationProvider.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ConfigurationProvider.cs new file mode 100644 index 0000000..828fe0a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ConfigurationProvider.cs @@ -0,0 +1,35 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Microsoft.Extensions.Configuration; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration +{ + /// + /// Builds the API Platform configuration used by the integration tests. + /// + /// Endpoints come from appsettings.json. Credentials are deliberately left blank there and must + /// be supplied out of band — either through appsettings.Development.json (git ignored) or through + /// environment variables, for example: + /// + /// ApiPlatform__CareIdentity__ClientId + /// ApiPlatform__CareIdentity__ClientSecret + /// + internal static class ConfigurationProvider + { + internal static ApiPlatformConfigurations GetApiPlatformConfigurations() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + return configuration + .GetSection("ApiPlatform") + .Get() ?? new ApiPlatformConfigurations(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ServiceCollectionExtensionsTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..e610cc0 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,179 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void ShouldResolveApiPlatformClientFromTheComposedAspNetCoreContainer() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + // when + var actualClient = serviceScope.ServiceProvider.GetRequiredService(); + + // then + actualClient.CareIdentityServiceClient.Should().NotBeNull(); + actualClient.PersonalDemographicsServiceClient.Should().NotBeNull(); + } + + [Fact] + public void ShouldOverrideTheInMemoryStorageBrokersWithSessionBackedOnes() + { + // given + // AddApiPlatformSdkInMemoryStorage uses TryAdd, so the session brokers only win if + // AddApiPlatformSdkAspNetCore has already registered them. Registering the in-memory + // ones here is what makes this assertion capable of failing. + ServiceProvider serviceProvider = BuildServiceProvider(withInMemoryStorage: true); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + // when + var actualStateBroker = + serviceScope.ServiceProvider.GetRequiredService(); + + var actualTokenBroker = + serviceScope.ServiceProvider.GetRequiredService(); + + // then + actualStateBroker.GetType().Name.Should().Be("SessionApiPlatformStateBroker"); + actualTokenBroker.GetType().Name.Should().Be("SessionApiPlatformTokenBroker"); + } + + [Fact] + public void ShouldStillUseTheSessionBrokersWhenInMemoryStorageIsRegisteredFirst() + { + // given + // Registration order does NOT matter here, contrary to what one might expect from + // TryAdd: AddApiPlatformSdkAspNetCore uses AddScoped, which appends rather than + // no-ops, and the last registration for a service is the one that resolves. So the + // session brokers win either way, and a web host cannot accidentally end up on the + // process-wide singletons by ordering these two calls the "wrong" way round. + ApiPlatformConfigurations configurations = + ConfigurationProvider.GetApiPlatformConfigurations(); + + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton( + new HttpContextAccessor { HttpContext = CreateHttpContext() }); + + services.AddApiPlatformSdkCore(configurations); + services.AddApiPlatformSdkInMemoryStorage(); + services.AddApiPlatformSdkAspNetCore(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + // when + var actualStateBroker = + serviceScope.ServiceProvider.GetRequiredService(); + + // then + actualStateBroker.GetType().Name.Should().Be("SessionApiPlatformStateBroker"); + } + + [Fact] + public async Task ShouldRoundTripTheCsrfStateThroughTheSessionAsync() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + var stateBroker = + serviceScope.ServiceProvider.GetRequiredService(); + + string randomState = Guid.NewGuid().ToString(); + + // when + await stateBroker.StoreCsrfStateAsync(randomState); + + // then + string actualState = await stateBroker.GetCsrfStateAsync(); + actualState.Should().Be(randomState); + } + + [Fact] + public async Task ShouldRoundTripTheAccessTokenThroughTheSessionAsync() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + var tokenBroker = + serviceScope.ServiceProvider.GetRequiredService(); + + string randomAccessToken = Guid.NewGuid().ToString(); + DateTimeOffset expiresAtUtc = DateTimeOffset.UtcNow.AddHours(1); + + // when + await tokenBroker.StoreAccessTokenAsync(randomAccessToken, expiresAtUtc); + + // then + var (actualToken, _) = await tokenBroker.GetAccessTokenAsync(); + actualToken.Should().Be(randomAccessToken); + } + + private static DefaultHttpContext CreateHttpContext() => + new DefaultHttpContext + { + Session = new IntegrationSession() + }; + + private static ServiceProvider BuildServiceProvider(bool withInMemoryStorage = false) + { + ApiPlatformConfigurations configurations = + ConfigurationProvider.GetApiPlatformConfigurations(); + + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton( + new HttpContextAccessor { HttpContext = CreateHttpContext() }); + + services.AddApiPlatformSdkCore(configurations); + services.AddApiPlatformSdkAspNetCore(); + + if (withInMemoryStorage) + { + services.AddApiPlatformSdkInMemoryStorage(); + } + + return services.BuildServiceProvider(); + } + + private sealed class IntegrationSession : ISession + { + private readonly Dictionary store = new Dictionary(); + + public bool IsAvailable => true; + public string Id => "integration-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.Integration/appsettings.json b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/appsettings.json index 2c63c08..bb7db00 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/appsettings.json +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/appsettings.json @@ -1,2 +1,16 @@ { + "ApiPlatform": { + "CareIdentity": { + "AuthEndpoint": "https://int.api.service.nhs.uk/oauth2/authorize", + "TokenEndpoint": "https://int.api.service.nhs.uk/oauth2/token", + "UserInfoEndpoint": "https://int.api.service.nhs.uk/oauth2/userinfo", + "RedirectUri": "https://localhost:5174/auth/callback", + "ClientId": "", + "ClientSecret": "", + "AcrValues": "" + }, + "PersonalDemographicsService": { + "BaseUrl": "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" + } + } } 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..5a6f2bb --- /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(2)) + .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..43cc5c3 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/Clients/ApiPlatforms/ApiPlatformClientTests.Exceptions.cs @@ -0,0 +1,124 @@ +// --------------------------------------------------------- +// 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 ShouldThrowDependencyValidationExceptionOnGetUserInfoIfStateDoesNotMatchAsync() + { + // 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.BadGateway)] + 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(); + } + + [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(); + } + + [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.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..9a6657b --- /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: 1000000000, max: 1999999999).GetValue().ToString(); + + public void Dispose() + { + this.wireMockServer.Stop(); + this.wireMockServer.Dispose(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs new file mode 100644 index 0000000..84132b5 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs @@ -0,0 +1,72 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfStateWasNeverIssuedAsync() + { + // given + string unknownState = GetRandomString(); + string authorisationCode = GetRandomString(); + + // when + CareIdentityServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(authorisationCode, unknownState)); + + // then + actualException.InnerException.Message.Should().Be("Invalid state parameter."); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfCodeIsMissingAsync() + { + // given + string emptyCode = string.Empty; + string randomState = GetRandomString(); + + // when + // then + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(emptyCode, randomState)); + } + + [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(Skip = "Requires NHS CIS2 credentials and reaches the live INT token endpoint.")] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfAuthorisationCodeIsRejectedAsync() + { + // given + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractQueryValue(loginUrl, "state"); + string rejectedCode = GetRandomString(); + + // when + // then + await Assert.ThrowsAsync(async () => + await this.careIdentityServiceClient.GetUserInfoAsync(rejectedCode, state)); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs new file mode 100644 index 0000000..0f2d007 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs @@ -0,0 +1,86 @@ +// --------------------------------------------------------- +// 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.Integration.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldBuildLoginUrlAgainstTheConfiguredAuthEndpointAsync() + { + // given + string expectedAuthEndpoint = this.apiPlatformConfigurations.CareIdentity.AuthEndpoint; + + // when + string actualLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + expectedAuthEndpoint.Should().NotBeNullOrWhiteSpace( + "appsettings.json must supply the CIS2 authorisation endpoint"); + + actualLoginUrl.Should().StartWith(expectedAuthEndpoint); + } + + [Fact] + public async Task ShouldIssueAUniqueCsrfStateOnEachBuildLoginUrlAsync() + { + // given + string firstLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // when + string secondLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + string firstState = ExtractQueryValue(firstLoginUrl, "state"); + string secondState = ExtractQueryValue(secondLoginUrl, "state"); + firstState.Should().NotBeNullOrWhiteSpace(); + secondState.Should().NotBe(firstState); + } + + [Fact] + public async Task ShouldReturnEmptyAccessTokenBeforeAnyLoginAsync() + { + // given + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldLogoutWithoutAnEstablishedSessionAsync() + { + // given + // when + await this.careIdentityServiceClient.LogoutAsync(); + + // then + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + actualAccessToken.Should().BeEmpty(); + } + + [Fact(Skip = "Requires NHS CIS2 credentials and an interactive authorisation code.")] + public async Task ShouldReturnUserInfoOnCompletingTheLoginFlowAsync() + { + // given + string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + string state = ExtractQueryValue(loginUrl, "state"); + string authorisationCode = GetRandomString(); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceClient.GetUserInfoAsync(authorisationCode, state); + + // then + actualUserInfo.Should().NotBeNull(); + actualUserInfo.NhsIdUserUid.Should().NotBeNullOrWhiteSpace(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs new file mode 100644 index 0000000..cf671db --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs @@ -0,0 +1,48 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Microsoft.Extensions.Configuration; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; +using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly IApiPlatformClient apiPlatformClient; + private readonly ICareIdentityServiceClient careIdentityServiceClient; + + public CareIdentityServiceClientTests() + { + this.apiPlatformConfigurations = ConfigurationProvider.GetApiPlatformConfigurations(); + this.apiPlatformClient = new ApiPlatformClient(this.apiPlatformConfigurations); + this.careIdentityServiceClient = this.apiPlatformClient.CareIdentityServiceClient; + } + + private static string ExtractQueryValue(string url, string key) + { + string query = new Uri(url).Query.TrimStart('?'); + + foreach (string pair in query.Split('&')) + { + string[] parts = pair.Split('='); + + if (parts.Length == 2 && parts[0] == key) + { + return parts[1]; + } + } + + return string.Empty; + } + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs new file mode 100644 index 0000000..9ec2df3 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs @@ -0,0 +1,104 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; +using NHSDigital.ApiPlatform.Sdk.Clients.PersonalDemographicsServices; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration.Clients.PersonalDemographicsServices +{ + public class PersonalDemographicsServiceClientTests + { + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; + + public PersonalDemographicsServiceClientTests() + { + this.apiPlatformConfigurations = ConfigurationProvider.GetApiPlatformConfigurations(); + var apiPlatformClient = new ApiPlatformClient(this.apiPlatformConfigurations); + this.personalDemographicsServiceClient = apiPlatformClient.PersonalDemographicsServiceClient; + } + + [Fact] + public void ShouldResolveThePersonalDemographicsServiceBaseUrlFromConfiguration() + { + // given + // when + string actualBaseUrl = this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl; + + // then + actualBaseUrl.Should().NotBeNullOrWhiteSpace( + "appsettings.json must supply the PDS FHIR base url"); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + SearchCriteria nullSearchCriteria = null; + + // when + // then + await Assert.ThrowsAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync(nullSearchCriteria)); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNotAuthenticatedAsync() + { + // given + var searchCriteria = new SearchCriteria { NhsNumber = 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 + var searchCriteria = new SearchCriteria { NhsNumber = GetRandomNhsNumber() }; + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + // then + await Assert.ThrowsAnyAsync(async () => + await this.personalDemographicsServiceClient.SearchPatientsAsync( + searchCriteria, + cancellationTokenSource.Token)); + } + + [Fact(Skip = "Requires NHS CIS2 credentials and reaches the live INT PDS endpoint.")] + public async Task ShouldSearchPatientsByNhsNumberAsync() + { + // given + var searchCriteria = new SearchCriteria { NhsNumber = "9000000009" }; + + // when + string actualPayload = + await this.personalDemographicsServiceClient.SearchPatientsAsync(searchCriteria); + + // then + actualPayload.Should().Contain("Patient"); + } + + private static string GetRandomNhsNumber() => + new IntRange(min: 100000000, max: 999999999).GetValue().ToString(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ConfigurationProvider.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ConfigurationProvider.cs new file mode 100644 index 0000000..1168506 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ConfigurationProvider.cs @@ -0,0 +1,39 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Microsoft.Extensions.Configuration; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration +{ + /// + /// Builds the API Platform configuration used by the integration tests. + /// + /// Endpoints come from appsettings.json. Credentials are deliberately left blank there and must + /// be supplied out of band — either through appsettings.Development.json (git ignored) or through + /// environment variables, for example: + /// + /// ApiPlatform__CareIdentity__ClientId + /// ApiPlatform__CareIdentity__ClientSecret + /// + /// Tests that require a live NHS API Platform conversation are marked with an explicit + /// [Fact(Skip = "...")] rather than being silently skipped on missing configuration, so that a + /// run without credentials reports them as skipped instead of passing vacuously. + /// + internal static class ConfigurationProvider + { + internal static ApiPlatformConfigurations GetApiPlatformConfigurations() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + return configuration + .GetSection("ApiPlatform") + .Get() ?? new ApiPlatformConfigurations(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.BuildLoginUrl.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.BuildLoginUrl.cs deleted file mode 100644 index ff36417..0000000 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.BuildLoginUrl.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Threading.Tasks; -using Xunit; - -namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration -{ - public partial class NhsLoginTests - { - [Fact] - public async Task BuildLoginUrl() - { - // given - // when - string loginUrl = await careIdentityServiceClient.BuildLoginUrlAsync(); - - // then - Assert.False(string.IsNullOrWhiteSpace(loginUrl), "Login URL should not be null or empty."); - Assert.Contains(apiPlatformConfigurations.CareIdentity.AuthEndpoint, loginUrl); - } - } -} \ No newline at end of file diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetAccessToken.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetAccessToken.cs deleted file mode 100644 index 9fd7ea7..0000000 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetAccessToken.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; -using Xunit; - -namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration -{ - public partial class NhsLoginTests - { - [Fact] - public async Task GetAccessToken() - { - // given - // when - await careIdentityServiceClient.GetAccessTokenAsync(); - - // then - Assert.True(true, "Logout completed successfully without throwing an exception."); - } - } -} \ No newline at end of file diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetUserInfo.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetUserInfo.cs deleted file mode 100644 index 0df8cb2..0000000 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.GetUserInfo.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Threading.Tasks; -using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; -using Xunit; - -namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration -{ - public partial class NhsLoginTests - { - [Fact(Skip = "Requires real NHS authentication flow with valid authorization code")] - public async Task GetUserInfo() - { - // given - string code = "test-authorization-code"; - string state = "test-state-value"; - - // when - NhsUserInfo userInfo = - await careIdentityServiceClient.GetUserInfoAsync( - code, - state); - - // then - Assert.NotNull(userInfo); - } - } -} \ No newline at end of file diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.Logout.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.Logout.cs deleted file mode 100644 index 9d7c591..0000000 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.Logout.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; -using Xunit; - -namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration -{ - public partial class NhsLoginTests - { - [Fact] - public async Task Logout() - { - // given - // when - await careIdentityServiceClient.LogoutAsync(); - - // then - Assert.True(true, "Logout completed successfully without throwing an exception."); - } - } -} \ No newline at end of file diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.cs deleted file mode 100644 index 9b6dd50..0000000 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/NhsLoginTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -// --------------------------------------------------------- -// Copyright (c) North East London ICB. All rights reserved. -// --------------------------------------------------------- - -using Microsoft.Extensions.Configuration; -using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; -using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; -using NHSDigital.ApiPlatform.Sdk.Models.Configurations; -using Xunit.Abstractions; - -namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration -{ - public partial class NhsLoginTests - { - private readonly ICareIdentityServiceClient careIdentityServiceClient; - private readonly ApiPlatformConfigurations apiPlatformConfigurations; - private readonly IConfiguration configuration; - private readonly ITestOutputHelper output; - - public NhsLoginTests(ITestOutputHelper output) - { - this.output = output; - - var configurationBuilder = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(); - - configuration = configurationBuilder.Build(); - - this.apiPlatformConfigurations = configuration - .GetSection("CIS").Get(); - - var apiPlatformClient = new ApiPlatformClient(this.apiPlatformConfigurations); - this.careIdentityServiceClient = apiPlatformClient.CareIdentityServiceClient; - } - } -} \ No newline at end of file diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ServiceCollectionExtensionsTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..c7ba41e --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,99 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +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 Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void ShouldResolveApiPlatformClientFromTheComposedContainer() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + + // when + var actualClient = serviceProvider.GetRequiredService(); + + // then + actualClient.CareIdentityServiceClient.Should().NotBeNull(); + actualClient.PersonalDemographicsServiceClient.Should().NotBeNull(); + } + + [Fact] + public void ShouldResolveCareIdentityServiceClientFromTheComposedContainer() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + + // when + var actualClient = serviceProvider.GetRequiredService(); + + // then + actualClient.Should().NotBeNull(); + } + + [Fact] + public void ShouldResolvePersonalDemographicsServiceClientFromTheComposedContainer() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + + // when + var actualClient = serviceProvider.GetRequiredService(); + + // then + actualClient.Should().NotBeNull(); + } + + [Fact] + public void ShouldFallBackToInMemoryStorageBrokersWhenNoneAreSupplied() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + + // when + var actualStateBroker = serviceProvider.GetRequiredService(); + var actualTokenBroker = serviceProvider.GetRequiredService(); + + // then + actualStateBroker.GetType().Name.Should().Be("MemoryApiPlatformStateBroker"); + actualTokenBroker.GetType().Name.Should().Be("MemoryApiPlatformTokenBroker"); + } + + [Fact] + public void ShouldShareStorageBrokersAcrossResolutions() + { + // given + ServiceProvider serviceProvider = BuildServiceProvider(); + + // when + var firstTokenBroker = serviceProvider.GetRequiredService(); + var secondTokenBroker = serviceProvider.GetRequiredService(); + + // then + firstTokenBroker.Should().BeSameAs(secondTokenBroker); + } + + private static ServiceProvider BuildServiceProvider() + { + ApiPlatformConfigurations configurations = + ConfigurationProvider.GetApiPlatformConfigurations(); + + IServiceCollection services = new ServiceCollection(); + services.AddApiPlatformSdkCore(configurations); + services.AddApiPlatformSdkInMemoryStorage(); + + return services.BuildServiceProvider(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.json b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.json index 40e7773..bb7db00 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.json +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.json @@ -1,13 +1,16 @@ { - "CIS": { - "AuthEndpoint": "https://int.api.service.nhs.uk/oauth2/authorize", - "TokenEndpoint": "https://int.api.service.nhs.uk/oauth2/token", - "UserInfoEndpoint": "https://int.api.service.nhs.uk/oauth2/userinfo", - "LogoutEndpoint": "https://int.api.service.nhs.uk/oauth2/logout", - "PostLogoutRedirectUri": "https://localhost:5174/", - "ClientId": "CsVVAJodqwlRPH479GedNmeCbcWNZ8jW", - "ClientSecret": "HKD8tYgfgFtCf3G0", - "RedirectUri": "https://localhost:5174/auth/callback", - "AALLevel": "AAL2_OR_AAL3_ANY" + "ApiPlatform": { + "CareIdentity": { + "AuthEndpoint": "https://int.api.service.nhs.uk/oauth2/authorize", + "TokenEndpoint": "https://int.api.service.nhs.uk/oauth2/token", + "UserInfoEndpoint": "https://int.api.service.nhs.uk/oauth2/userinfo", + "RedirectUri": "https://localhost:5174/auth/callback", + "ClientId": "", + "ClientSecret": "", + "AcrValues": "" + }, + "PersonalDemographicsService": { + "BaseUrl": "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4" + } } }