Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 12 additions & 6 deletions Documentation/DependencyGraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IApiPlatformStateBroker>()` 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`,
Expand Down
4 changes: 2 additions & 2 deletions Documentation/DependencyGraph/graph-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, byte[]> store = new Dictionary<string, byte[]>();

public bool IsAvailable => true;
public string Id => "acceptance-session";
public IEnumerable<string> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<PersonalDemographicsServiceClientValidationException>(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<OperationCanceledException>(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<IApiPlatformClient>();

string loginUrl = await timeoutClient.CareIdentityServiceClient.BuildLoginUrlAsync();
string state = ExtractStateFromLoginUrl(loginUrl);

// when
CareIdentityServiceClientDependencyException actualException =
await Assert.ThrowsAsync<CareIdentityServiceClientDependencyException>(async () =>
await timeoutClient.CareIdentityServiceClient.GetUserInfoAsync(
GetRandomString(),
state));

// then
actualException.InnerException.InnerException.Should().BeOfType<TimeoutException>();

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<IApiPlatformClient>();

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<PersonalDemographicsServiceClientDependencyException>(async () =>
await timeoutClient.PersonalDemographicsServiceClient.SearchPatientsAsync(
searchCriteria));

// then
actualException.InnerException.InnerException.Should().BeOfType<TimeoutException>();
}
}
}
Loading
Loading