diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75e668b..49e7bf2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,19 +30,68 @@ jobs: run: >- $projects = Get-ChildItem -Path . -Filter "*Tests.Unit*.csproj" -Recurse + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Unit*.csproj" + exit 1 + } + + $failedProjects = @() + foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 } shell: pwsh - name: Run Acceptance Tests run: >- $projects = Get-ChildItem -Path . -Filter "*Tests.Acceptance*.csproj" -Recurse + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Acceptance*.csproj" + exit 1 + } + + $failedProjects = @() + foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 } + shell: pwsh + - name: Run Integration Tests + run: >- + $projects = Get-ChildItem -Path . -Filter "*Tests.Integration*.csproj" -Recurse + + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Integration*.csproj" + exit 1 + } + + $failedProjects = @() + + foreach ($project in $projects) { + Write-Host "Running tests for: $($project.FullName)" + dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 + } + shell: pwsh add_tag: name: Tag and Release runs-on: ubuntu-latest diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md index 25ab55e..d738540 100644 --- a/Documentation/DependencyGraph/README.md +++ b/Documentation/DependencyGraph/README.md @@ -46,12 +46,12 @@ view you were on, and switching carries your current selection across. - Whatever is selected is outlined and lettered in **amber**; rows the traced path passes through carry a faint blue tint. Click the background or Reset to clear. Search finds components and methods. The **utility brokers** - toggle reveals the DateTime / Identifier broker copies that are hidden by + toggle reveals the DateTime / Identifier / Logging broker copies and the logging external that are hidden by default for readability. -At the last scan, 27 declared components and 84 declared edges draw as +At the last scan, 29 declared components and 86 declared edges draw as **25 components · 79 flows** in the single-copy view and **100 nodes · -413 flows** per consumer (27 · 84 and 113 · 441 with utility brokers on). +413 flows** per consumer (29 · 86 and 115 · 443 with utility brokers on). `.github/workflows/pages.yml` publishes this folder to GitHub Pages on every push to `main` that touches it — `index.html` is the site root. Nothing is @@ -71,9 +71,15 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions). `IApiPlatformClient` holding the same two sub-clients, but nothing constructs or registers it: `AddApiPlatformSdkCore` registers a hand-built `ApiPlatformClient` instead. It shows on the graph with no inbound flows. -- **`PdsOrchestrationService` takes `IApiPlatformTokenBroker` and never uses - it.** The access token comes from `CareIdentityService.GetAccessTokenAsync`; - the injected broker is unused. +- **`LoggingBroker` has no inbound flows on this graph, by design.** Every + service takes `ILoggingBroker`, but it is only ever reached from the + `CreateAndLog*` exception factories, and this graph draws happy-path calls + only. It is a utility broker, so it is hidden behind the toggle along with + the DateTime and Identifier brokers. +- **Dependency failures are categorised by HTTP status.** Both foundation + 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 diff --git a/Documentation/DependencyGraph/graph-data.js b/Documentation/DependencyGraph/graph-data.js index d6f51d8..ec5b602 100644 --- a/Documentation/DependencyGraph/graph-data.js +++ b/Documentation/DependencyGraph/graph-data.js @@ -35,6 +35,7 @@ { id: "ext-aspnetcore", name: "ASP.NET Core", kind: "external" }, { id: "ext-bcl", name: ".NET base class library", kind: "external" }, { id: "ext-adotnet", name: "ADotNet", kind: "external" }, + { id: "ext-logging", name: "Microsoft.Extensions.Logging", kind: "external" }, { id: "ext-nhs", name: "NHS Digital API Platform (remote)", kind: "external" }, ]; @@ -69,6 +70,9 @@ description: "ASP.NET Core session state. The Sdk.AspNetCore brokers throw when there is no HttpContext or session — the host must have called UseSession()." }); C({ id: "EXT.Bcl", name: "System.Security.Cryptography / Text.Json", project: "ext-bcl", layer: "external", col: 8, shared: true, methods: [], description: "RandomNumberGenerator for the CSRF state, System.Text.Json (Web defaults) for payloads, Guid.NewGuid for the PDS X-Request-ID, DateTimeOffset.UtcNow for token expiry." }); + C({ id: "EXT.Logging", name: "Microsoft.Extensions.Logging", project: "ext-logging", layer: "external", col: 8, + shared: true, utility: true, methods: [], + description: "AddApiPlatformSdkCore calls AddLogging(), so a host that configures no providers still resolves an ILoggerFactory and the SDK's error logging goes nowhere rather than failing." }); C({ id: "EXT.Cis2", name: "NHS CIS2 (Care Identity Service)", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], description: "OAuth2 authorization-code flow without PKCE — CIS2 does not support it. Auth, token and userinfo endpoints come from CareIdentityConfigurations." }); C({ id: "EXT.Pds", name: "NHS Personal Demographics Service", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], @@ -121,7 +125,7 @@ ================================================================== */ C({ id: "PDS.Orchestration", name: "PdsOrchestrationService", project: "sdk", layer: "orchestration", col: 3, methods: ["SearchPatientsAsync"], - description: "Gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService. NOTE: it also takes IApiPlatformTokenBroker in its constructor but never calls it — the token comes from CareIdentityService." }); + description: "Validates the search criteria, gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService." }); D(["PDS.Orchestration", "SearchPatientsAsync"], ["CIS.Foundation", "GetAccessTokenAsync"]); D(["PDS.Orchestration", "SearchPatientsAsync"], ["PDS.Foundation", "SearchPatientsAsync"]); @@ -196,6 +200,12 @@ methods: ["GetNewGuid"] }); D(["IdentifierBroker", "GetNewGuid"], ["EXT.Bcl", "Guid.NewGuid"]); + C({ id: "LoggingBroker", name: "LoggingBroker", project: "sdk", layer: "broker", col: 5, utility: true, + methods: ["LogErrorAsync", "LogCriticalAsync"], + description: "Wraps ILogger. Every service takes it, but it is only ever reached from the CreateAndLog* exception factories — which this graph deliberately does not draw — so it has no inbound flows here. Registered with TryAddSingleton so a host can substitute its own." }); + D(["LoggingBroker", "LogErrorAsync"], ["EXT.Logging", "ILogger.LogError"]); + D(["LoggingBroker", "LogCriticalAsync"], ["EXT.Logging", "ILogger.LogCritical"]); + /* -- the two swappable storage brokers ------------------------------- Both interfaces have an in-memory implementation shipped in the Sdk and a session-backed one in Sdk.AspNetCore. Which one you get is a @@ -262,14 +272,14 @@ "CIS.Client", "PDS.Client", "CIS.Processing", "PDS.Orchestration", "CIS.Foundation", "PDS.Foundation", - "HttpBroker", "CryptoBroker", "JsonBroker", "DateTimeBroker", "IdentifierBroker", + "HttpBroker", "CryptoBroker", "JsonBroker", "DateTimeBroker", "IdentifierBroker", "LoggingBroker", "StateBroker", "TokenBroker", "MemoryStateBroker", "MemoryTokenBroker", // NHSDigital.ApiPlatform.Sdk.AspNetCore "SessionStateBroker", "SessionTokenBroker", // NHSDigital.ApiPlatform.Infrastructure "INF.Program", "INF.ScriptGeneration", // externals - "EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet", + "EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Logging", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet", ); /* ------------------------------------------------------------------ @@ -277,7 +287,7 @@ Derive their method rows from the declared edges so the rows and the arrows can never drift apart. ------------------------------------------------------------------ */ - for (const extId of ["EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet"]) { + for (const extId of ["EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Logging", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet"]) { const comp = components.find(c => c.id === extId); const called = []; for (const e of edges) { diff --git a/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs b/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs index cdb60a7..7beab6a 100644 --- a/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs +++ b/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs @@ -80,27 +80,21 @@ public void GenerateBuildScript(string branchName, string projectName, string do { Name = "Run Unit Tests", Shell = "pwsh", - Run = - """ - $projects = Get-ChildItem -Path . -Filter "*Tests.Unit*.csproj" -Recurse - foreach ($project in $projects) { - Write-Host "Running tests for: $($project.FullName)" - dotnet test $project.FullName --no-build --verbosity normal - } - """ + Run = CreateTestRunScript("*Tests.Unit*.csproj") }, new TestTask { Name = "Run Acceptance Tests", - Run = - """ - $projects = Get-ChildItem -Path . -Filter "*Tests.Acceptance*.csproj" -Recurse - foreach ($project in $projects) { - Write-Host "Running tests for: $($project.FullName)" - dotnet test $project.FullName --no-build --verbosity normal - } - """ + Shell = "pwsh", + Run = CreateTestRunScript("*Tests.Acceptance*.csproj") + }, + + new TestTask + { + Name = "Run Integration Tests", + Shell = "pwsh", + Run = CreateTestRunScript("*Tests.Integration*.csproj") } } } @@ -197,5 +191,30 @@ public void GeneratePrLintScript(string branchName) adoPipeline: githubPipeline, path: buildScriptPath); } + + /// + /// Runs every test project matching and fails the step if ANY of + /// them failed. A bare loop reports only the last project's exit code, which silently hides a + /// failure in every project but the last. An empty match is also a failure — it means the glob has + /// drifted away from the projects it was meant to cover. + /// + private static string CreateTestRunScript(string projectFilter) => + $$""" + $projects = Get-ChildItem -Path . -Filter "{{projectFilter}}" -Recurse + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched {{projectFilter}}" + exit 1 + } + $failedProjects = @() + foreach ($project in $projects) { + Write-Host "Running tests for: $($project.FullName)" + dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 + } + """; } } diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs new file mode 100644 index 0000000..3475d5c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/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.Unit.Brokers.Storages +{ + internal sealed class FakeSession : ISession + { + private readonly Dictionary store = new Dictionary(); + + public bool IsAvailable => true; + public string Id => "fake-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.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs new file mode 100644 index 0000000..e35843c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs @@ -0,0 +1,102 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit.Brokers.Storages +{ + public class SessionApiPlatformStateBrokerTests + { + private readonly Mock httpContextAccessorMock; + private readonly FakeSession fakeSession; + private readonly IApiPlatformStateBroker apiPlatformStateBroker; + + public SessionApiPlatformStateBrokerTests() + { + this.httpContextAccessorMock = new Mock(); + this.fakeSession = new FakeSession(); + + var httpContext = new DefaultHttpContext + { + Session = this.fakeSession + }; + + this.httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns(httpContext); + + this.apiPlatformStateBroker = + new SessionApiPlatformStateBroker(this.httpContextAccessorMock.Object); + } + + [Fact] + public async Task ShouldStoreCsrfStateAsync() + { + // given + string randomState = GetRandomString(); + + // when + await this.apiPlatformStateBroker.StoreCsrfStateAsync(randomState); + + // then + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + actualState.Should().Be(randomState); + } + + [Fact] + public async Task ShouldReturnNullOnGetCsrfStateIfStateWasNeverStoredAsync() + { + // given + // when + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + + // then + actualState.Should().BeNull(); + } + + [Fact] + public async Task ShouldClearCsrfStateAsync() + { + // given + await this.apiPlatformStateBroker.StoreCsrfStateAsync(GetRandomString()); + + // when + await this.apiPlatformStateBroker.ClearCsrfStateAsync(); + + // then + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + actualState.Should().BeNull(); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionOnStoreCsrfStateIfHttpContextIsMissingAsync() + { + // given + var httpContextAccessorMock = new Mock(); + + httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns((HttpContext)null); + + IApiPlatformStateBroker stateBroker = + new SessionApiPlatformStateBroker(httpContextAccessorMock.Object); + + // when + // then + await Assert.ThrowsAsync(async () => + await stateBroker.StoreCsrfStateAsync(GetRandomString())); + } + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs new file mode 100644 index 0000000..0922976 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs @@ -0,0 +1,218 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit.Brokers.Storages +{ + public class SessionApiPlatformTokenBrokerTests + { + private readonly Mock httpContextAccessorMock; + private readonly IApiPlatformTokenBroker apiPlatformTokenBroker; + + public SessionApiPlatformTokenBrokerTests() + { + this.httpContextAccessorMock = new Mock(); + + var httpContext = new DefaultHttpContext + { + Session = new FakeSession() + }; + + this.httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns(httpContext); + + this.apiPlatformTokenBroker = + new SessionApiPlatformTokenBroker(this.httpContextAccessorMock.Object); + } + + [Fact] + public async Task ShouldStoreAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + // when + await this.apiPlatformTokenBroker.StoreAccessTokenAsync(randomAccessToken, randomExpiresAtUtc); + + // then + var (actualToken, _) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldStoreAccessTokenExpiryToTheSecondAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + DateTimeOffset expectedExpiresAtUtc = + DateTimeOffset.FromUnixTimeSeconds(randomExpiresAtUtc.ToUnixTimeSeconds()); + + // when + await this.apiPlatformTokenBroker.StoreAccessTokenAsync(randomAccessToken, randomExpiresAtUtc); + + // then + var (_, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualExpiresAtUtc.Should().Be(expectedExpiresAtUtc); + } + + [Fact] + public async Task ShouldReturnNullsOnGetAccessTokenIfTokenWasNeverStoredAsync() + { + // given + // when + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + + // then + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldClearAccessTokenAsync() + { + // given + await this.apiPlatformTokenBroker.StoreAccessTokenAsync( + GetRandomString(), + GetRandomDateTimeOffset()); + + // when + await this.apiPlatformTokenBroker.ClearAccessTokenAsync(); + + // then + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldStoreRefreshTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + // when + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync(randomRefreshToken, randomExpiresAtUtc); + + // then + var (actualToken, _) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualToken.Should().Be(randomRefreshToken); + } + + [Fact] + public async Task ShouldStoreRefreshTokenExpiryToTheSecondAsync() + { + // given + // This expiry is the sole input to the decision to silently refresh or sign the user out, + // so the round trip through the session has to preserve it. + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + DateTimeOffset expectedExpiresAtUtc = + DateTimeOffset.FromUnixTimeSeconds(randomExpiresAtUtc.ToUnixTimeSeconds()); + + // when + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync(randomRefreshToken, randomExpiresAtUtc); + + // then + var (_, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualExpiresAtUtc.Should().Be(expectedExpiresAtUtc); + } + + [Fact] + public async Task ShouldReturnNullsOnGetRefreshTokenIfTokenWasNeverStoredAsync() + { + // given + // when + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + + // then + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldClearRefreshTokenAsync() + { + // given + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync( + GetRandomString(), + GetRandomDateTimeOffset()); + + // when + await this.apiPlatformTokenBroker.ClearRefreshTokenAsync(); + + // then + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldStoreActiveRoleAsync() + { + // given + string randomRoleId = GetRandomString(); + + // when + await this.apiPlatformTokenBroker.StoreActiveRoleAsync(randomRoleId); + + // then + string actualRoleId = await this.apiPlatformTokenBroker.GetActiveRoleAsync(); + actualRoleId.Should().Be(randomRoleId); + } + + [Fact] + public async Task ShouldClearActiveRoleAsync() + { + // given + await this.apiPlatformTokenBroker.StoreActiveRoleAsync(GetRandomString()); + + // when + await this.apiPlatformTokenBroker.ClearActiveRoleAsync(); + + // then + string actualRoleId = await this.apiPlatformTokenBroker.GetActiveRoleAsync(); + actualRoleId.Should().BeNull(); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionOnGetAccessTokenIfHttpContextIsMissingAsync() + { + // given + var httpContextAccessorMock = new Mock(); + + httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns((HttpContext)null); + + IApiPlatformTokenBroker tokenBroker = + new SessionApiPlatformTokenBroker(httpContextAccessorMock.Object); + + // when + // then + await Assert.ThrowsAsync(async () => + await tokenBroker.GetAccessTokenAsync()); + } + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static DateTimeOffset GetRandomDateTimeOffset() => + new DateTimeRange(earliestDate: new DateTime(2020, 1, 1)).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..b4ff793 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,78 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Linq; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void ShouldRegisterHttpContextAccessorOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IHttpContextAccessor)); + } + + [Fact] + public void ShouldRegisterSessionStateBrokerOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + ServiceDescriptor actualDescriptor = services.Single(descriptor => + descriptor.ServiceType == typeof(IApiPlatformStateBroker)); + + actualDescriptor.ImplementationType.Should().Be(typeof(SessionApiPlatformStateBroker)); + actualDescriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void ShouldRegisterSessionTokenBrokerOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + ServiceDescriptor actualDescriptor = services.Single(descriptor => + descriptor.ServiceType == typeof(IApiPlatformTokenBroker)); + + actualDescriptor.ImplementationType.Should().Be(typeof(SessionApiPlatformTokenBroker)); + actualDescriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void ShouldReturnSameServiceCollectionOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + IServiceCollection actualServices = services.AddApiPlatformSdkAspNetCore(); + + // then + actualServices.Should().BeSameAs(services); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj b/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj index 5e0695a..82ff637 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -20,12 +20,13 @@ git NHSISL; NHS Digital; API; Platform; Client; .NET; The Standard; - Release 0.2.0.3: updated NHSDigital.ApiPlatform.Sdk.AspNetCore package metadata. + Release 0.3.0.0: tracks the NHSDigital.ApiPlatform.Sdk 0.3.0.0 exception and + cancellation contract changes. True - 0.2.0.3 - 0.2.0.3 - 0.2.0.3 + 0.3.0.0 + 0.3.0.0 + 0.3.0.0 README.md LICENSE.txt true diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs new file mode 100644 index 0000000..0ab7357 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs @@ -0,0 +1,168 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceClient.BuildLoginUrlAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + } + + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask logoutTask = + this.careIdentityServiceClient.LogoutAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await logoutTask); + } + + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceClient.GetUserInfoAsync( + randomCode, + randomState, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await getUserInfoTask); + } + + [Fact] + public async Task ShouldRethrowCancellationRaisedByTheProcessingServiceOnGetAccessTokenAsync() + { + // given + // The token is live when the call starts; the dependency is what raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceClient.GetAccessTokenAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + } + + [Fact] + public async Task ShouldSurfaceAProcessingTimeoutAsClientDependencyExceptionOnBuildLoginUrlAsync() + { + // given + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var processingDependencyException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + var expectedException = + new CareIdentityServiceClientDependencyException( + message: "Care identity service client dependency error occurred, contact support.", + innerException: timeoutCareIdentityServiceException); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(processingDependencyException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToProcessingServiceOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState, cancellationToken); + + // then + this.careIdentityServiceProcessingServiceMock.Verify(service => + service.GetUserInfoAsync(randomCode, randomState, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs new file mode 100644 index 0000000..a831458 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs @@ -0,0 +1,214 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + // The client repeats the same catch ladder in all four of its methods, so each category is + // exercised against every method rather than against whichever one happened to be picked. + public static TheoryData ClientOperations() => + new TheoryData + { + nameof(ICareIdentityServiceClientOperation.BuildLoginUrl), + nameof(ICareIdentityServiceClientOperation.Logout), + nameof(ICareIdentityServiceClientOperation.GetAccessToken), + nameof(ICareIdentityServiceClientOperation.GetUserInfo) + }; + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientValidationExceptionOnEveryOperationIfValidationErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingValidationException = + new CareIdentityServiceProcessingValidationException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingValidationException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientDependencyValidationExceptionOnEveryOperationAsync(string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingDependencyValidationException = + new CareIdentityServiceProcessingDependencyValidationException( + GetRandomString(), + innerException); + + SetupEveryOperationToThrow(processingDependencyValidationException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientDependencyExceptionOnEveryOperationIfDependencyErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingDependencyException = + new CareIdentityServiceProcessingDependencyException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingDependencyException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientServiceExceptionOnEveryOperationIfServiceErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingServiceException = + new CareIdentityServiceProcessingServiceException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingServiceException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientServiceExceptionOnEveryOperationIfUnexpectedErrorOccursAsync( + string operation) + { + // given + var unexpectedException = new InvalidOperationException(GetRandomString()); + SetupEveryOperationToThrow(unexpectedException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + + actualException.InnerException + .Should().BeOfType(); + + actualException.InnerException.InnerException.Should().BeSameAs(unexpectedException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldRethrowOperationCanceledExceptionOnEveryOperationAsync(string operation) + { + // given + SetupEveryOperationToThrow(new OperationCanceledException()); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeAssignableTo(); + } + + private void SetupEveryOperationToThrow(Exception exception) + { + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(exception); + } + + private async Task InvokeOperationAsync(string operation) + { + Exception actualException = await Record.ExceptionAsync(async () => + { + switch (operation) + { + case nameof(ICareIdentityServiceClientOperation.BuildLoginUrl): + await this.careIdentityServiceClient.BuildLoginUrlAsync(); + break; + + case nameof(ICareIdentityServiceClientOperation.Logout): + await this.careIdentityServiceClient.LogoutAsync(); + break; + + case nameof(ICareIdentityServiceClientOperation.GetAccessToken): + await this.careIdentityServiceClient.GetAccessTokenAsync(); + break; + + default: + await this.careIdentityServiceClient.GetUserInfoAsync( + GetRandomString(), + GetRandomString()); + + break; + } + }); + + actualException.Should().NotBeNull( + "every operation must surface the failure raised by the processing service"); + + return actualException; + } + + // Names only - gives the theory data compile-time safety against a renamed client operation. + private interface ICareIdentityServiceClientOperation + { + void BuildLoginUrl(); + void Logout(); + void GetAccessToken(); + void GetUserInfo(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs new file mode 100644 index 0000000..35d9582 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs @@ -0,0 +1,163 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Theory] + [MemberData(nameof(ValidationExceptions))] + public async Task ShouldThrowClientValidationExceptionOnBuildLoginUrlIfValidationErrorOccursAsync( + Xeption validationException) + { + // given + var expectedException = + new CareIdentityServiceClientValidationException( + message: "Care identity service client validation error occurred, fix errors and try again.", + innerException: validationException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(validationException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientValidationException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowClientDependencyValidationExceptionOnLogoutIfDependencyValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + var expectedException = + new CareIdentityServiceClientDependencyValidationException( + message: "Care identity service client dependency validation error occurred, " + + "fix errors and try again.", + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask logoutTask = this.careIdentityServiceClient.LogoutAsync(); + + CareIdentityServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowClientDependencyExceptionOnGetAccessTokenIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceClientDependencyException( + message: "Care identity service client dependency error occurred, contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowClientServiceExceptionOnGetUserInfoIfServiceErrorOccursAsync( + Xeption serviceException) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var expectedException = + new CareIdentityServiceClientServiceException( + message: "Care identity service client service error occurred, contact support.", + innerException: serviceException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); + + CareIdentityServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(UnexpectedExceptions))] + public async Task ShouldThrowClientServiceExceptionOnBuildLoginUrlIfUnexpectedErrorOccursAsync( + Exception unexpectedException) + { + // given + var failedCareIdentityServiceClientException = + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: unexpectedException, + data: unexpectedException.Data); + + var expectedException = + new CareIdentityServiceClientServiceException( + message: "Care identity service client service error occurred, contact support.", + innerException: failedCareIdentityServiceClientException); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(unexpectedException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs new file mode 100644 index 0000000..39fada1 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs @@ -0,0 +1,83 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomLoginUrl = GetRandomString(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ReturnsAsync(randomLoginUrl); + + // when + string actualLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(randomLoginUrl); + } + + [Fact] + public async Task ShouldLogoutAsync() + { + // given + // when + await this.careIdentityServiceClient.LogoutAsync(); + + // then + this.careIdentityServiceProcessingServiceMock.Verify(service => + service.LogoutAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldGetAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync(randomCode, randomState, It.IsAny())) + .ReturnsAsync(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs new file mode 100644 index 0000000..b970659 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs @@ -0,0 +1,92 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + private readonly Mock careIdentityServiceProcessingServiceMock; + private readonly ICareIdentityServiceClient careIdentityServiceClient; + + public CareIdentityServiceClientTests() + { + this.careIdentityServiceProcessingServiceMock = + new Mock(); + + this.careIdentityServiceClient = + new CareIdentityServiceClient(this.careIdentityServiceProcessingServiceMock.Object); + } + + public static TheoryData ValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingDependencyException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData UnexpectedExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new TimeoutException() + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..2d2524b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs @@ -0,0 +1,129 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldRethrowCancellationRaisedByTheOrchestrationOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + // The token is live when the call starts; the dependency is what raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldSurfaceAnOrchestrationTimeoutAsClientDependencyExceptionOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var orchestrationDependencyException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + + var expectedException = + new PersonalDemographicsServiceClientDependencyException( + message: "Personal demographics service client dependency error occurred, contact support.", + innerException: timeoutPdsServiceException); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(orchestrationDependencyException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToOrchestrationServiceOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationToken); + + // then + this.pdsOrchestrationServiceMock.Verify(service => + service.SearchPatientsAsync(randomSearchCriteria, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..1e03aa3 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs @@ -0,0 +1,174 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Theory] + [MemberData(nameof(ValidationExceptions))] + public async Task ShouldThrowClientValidationExceptionOnSearchPatientsIfValidationErrorOccursAsync( + Xeption validationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientValidationException( + message: "Personal demographics service client validation error occurred, " + + "fix errors and try again.", + + innerException: validationException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(validationException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowClientDependencyValidationExceptionOnSearchPatientsAsync( + Xeption dependencyValidationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientDependencyValidationException( + message: "Personal demographics service client dependency validation error occurred, " + + "fix errors and try again.", + + innerException: dependencyValidationException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientDependencyException( + message: "Personal demographics service client dependency error occurred, contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowClientServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Xeption serviceException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientServiceException( + message: "Personal demographics service client service error occurred, contact support.", + innerException: serviceException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(UnexpectedExceptions))] + public async Task ShouldThrowClientServiceExceptionOnSearchPatientsIfUnexpectedErrorOccursAsync( + Exception unexpectedException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var failedPersonalDemographicsServiceClientException = + new FailedPersonalDemographicsServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: unexpectedException, + data: unexpectedException.Data); + + var expectedException = + new PersonalDemographicsServiceClientServiceException( + message: "Personal demographics service client service error occurred, contact support.", + innerException: failedPersonalDemographicsServiceClientException); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(unexpectedException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..cf835a6 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs @@ -0,0 +1,39 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Fact] + public async Task ShouldSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + string randomPayload = GetRandomString(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(randomSearchCriteria, It.IsAny())) + .ReturnsAsync(randomPayload); + + // when + string actualPayload = + await this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + + this.pdsOrchestrationServiceMock.Verify(service => + service.SearchPatientsAsync(randomSearchCriteria, It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs new file mode 100644 index 0000000..5c8d2be --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs @@ -0,0 +1,87 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Clients.PersonalDemographicsServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + private readonly Mock pdsOrchestrationServiceMock; + private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; + + public PersonalDemographicsServiceClientTests() + { + this.pdsOrchestrationServiceMock = new Mock(); + + this.personalDemographicsServiceClient = + new PersonalDemographicsServiceClient(this.pdsOrchestrationServiceMock.Object); + } + + public static TheoryData ValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationDependencyException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData UnexpectedExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new TimeoutException() + }; + + private static SearchCriteria CreateRandomSearchCriteria() => + new SearchCriteria + { + NhsNumber = new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs new file mode 100644 index 0000000..b18cf85 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs @@ -0,0 +1,167 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityService.BuildLoginUrlAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + + this.cryptoBrokerMock.Verify(broker => + broker.CreateUrlSafeState(It.IsAny()), + Times.Never); + + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await logoutTask); + + this.stateBrokerMock.VerifyNoOtherCalls(); + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnCallbackIfCancellationRequestedAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync( + randomCode, + randomState, + cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await callbackTask); + + this.stateBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask getAccessTokenTask = + this.careIdentityService.GetAccessTokenAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() + { + // given + string randomAccessToken = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getUserInfoTask); + + this.httpBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + + // The token is live at entry so ThrowIfCancellationRequested lets us through; the broker is what + // cancels, which is the path a caller aborting an in-flight request actually takes. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .Throws(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync( + randomAccessToken, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await getUserInfoTask); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToBrokersOnLogoutAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + // when + await this.careIdentityService.LogoutAsync(cancellationToken); + + // then + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(cancellationToken), + Times.Once); + + this.tokenBrokerMock.Verify(broker => + broker.ClearAccessTokenAsync(cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs new file mode 100644 index 0000000..2edf305 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs @@ -0,0 +1,208 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldPostTheAuthorizationCodeGrantToTheTokenEndpointOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + IEnumerable> actualFormValues = null; + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (url, formValues, cancellationToken) => actualFormValues = formValues) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(It.IsAny())) + .Returns(randomTokenResult); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + Dictionary actualForm = + actualFormValues.ToDictionary(pair => pair.Key, pair => pair.Value); + + actualForm["grant_type"].Should().Be("authorization_code"); + actualForm["code"].Should().Be(randomCode); + actualForm["redirect_uri"].Should().Be(this.apiPlatformConfigurations.CareIdentity.RedirectUri); + actualForm["client_id"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientId); + actualForm["client_secret"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientSecret); + } + + [Fact] + public async Task ShouldPostTheRefreshTokenGrantToTheTokenEndpointOnGetAccessTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + IEnumerable> actualFormValues = null; + string randomTokenJson = GetRandomString(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, randomDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (url, formValues, cancellationToken) => actualFormValues = formValues) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + Dictionary actualForm = + actualFormValues.ToDictionary(pair => pair.Key, pair => pair.Value); + + actualForm["grant_type"].Should().Be("refresh_token"); + actualForm["refresh_token"].Should().Be(randomRefreshToken); + actualForm["client_id"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientId); + actualForm["client_secret"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientSecret); + } + + [Fact] + public async Task ShouldSendTheBearerTokenOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/userinfo"); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + actualRequest.Headers.Authorization.Scheme.Should().Be("Bearer"); + actualRequest.Headers.Authorization.Parameter.Should().Be(randomAccessToken); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfDependencyRejectsTheRequestAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.InnerException + .Should().BeOfType(); + + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfDependencyFailsAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.InnerException + .Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs new file mode 100644 index 0000000..37e7032 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs @@ -0,0 +1,298 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnBuildLoginUrlIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnLogoutIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnLogoutIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + string randomAccessToken = GetRandomString(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnGetUserInfoIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomAccessToken = GetRandomString(); + + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(serviceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnCallbackIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); + } + + private static CareIdentityServiceDependencyException CreateExpectedDependencyException( + Exception dependencyException) + { + Xeption expectedInnerException = dependencyException is TimeoutException + ? new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: dependencyException, + data: dependencyException.Data) + + : new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: dependencyException); + + return new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: expectedInnerException); + } + + private static CareIdentityServiceServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedCareIdentityServiceException = + new FailedCareIdentityServiceException( + message: "Failed care identity service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new CareIdentityServiceServiceException( + message: "Care identity service error occurred, please contact support.", + innerException: failedCareIdentityServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs new file mode 100644 index 0000000..fb45a18 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomCsrfState = GetRandomString(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(randomCsrfState); + + string expectedLoginUrl = + $"{this.apiPlatformConfigurations.CareIdentity.AuthEndpoint}" + + $"?client_id={this.apiPlatformConfigurations.CareIdentity.ClientId}" + + $"&redirect_uri=" + + $"{System.Uri.EscapeDataString(this.apiPlatformConfigurations.CareIdentity.RedirectUri)}" + + $"&response_type=code" + + $"&state={randomCsrfState}"; + + // when + string actualLoginUrl = await this.careIdentityService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(expectedLoginUrl); + } + + [Fact] + public async Task ShouldStoreCsrfStateOnBuildLoginUrlAsync() + { + // given + string randomCsrfState = GetRandomString(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(randomCsrfState); + + // when + await this.careIdentityService.BuildLoginUrlAsync(); + + // then + this.stateBrokerMock.Verify(broker => + broker.StoreCsrfStateAsync(randomCsrfState, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldAppendAcrValuesOnBuildLoginUrlIfAcrValuesAreConfiguredAsync() + { + // given + string randomAcrValues = GetRandomString(); + this.apiPlatformConfigurations.CareIdentity.AcrValues = randomAcrValues; + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(GetRandomString()); + + // when + string actualLoginUrl = await this.careIdentityService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().EndWith($"&acr_values={randomAcrValues}"); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs new file mode 100644 index 0000000..45a8168 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs @@ -0,0 +1,198 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldStoreAccessTokenOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.ExpiresIn)); + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreAccessTokenAsync( + randomTokenResult.AccessToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldStoreRefreshTokenOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.RefreshTokenExpiresIn)); + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + randomTokenResult.RefreshToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreRefreshTokenOnCallbackIfRefreshTokenIsMissingAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + randomTokenResult.RefreshToken = string.Empty; + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldClearCsrfStateOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldStoreActiveRoleOnCallbackIfUserHasRolesAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + string expectedRoleId = randomUserInfo.NhsIdNrbacRoles[0].PersonRoleId; + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreActiveRoleAsync(expectedRoleId, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreActiveRoleOnCallbackIfUserHasNoRolesAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + randomUserInfo.NhsIdNrbacRoles = new List(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreActiveRoleAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + private void SetupSuccessfulCallback( + string csrfState, + DateTimeOffset currentDateTimeOffset, + TokenResult tokenResult, + NhsUserInfo userInfo) + { + string randomTokenJson = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync(csrfState); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + this.apiPlatformConfigurations.CareIdentity.TokenEndpoint, + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(tokenResult); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(userInfo); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(currentDateTimeOffset); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs new file mode 100644 index 0000000..db42d10 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs @@ -0,0 +1,283 @@ +// --------------------------------------------------------- +// 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 Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldReturnStoredAccessTokenOnGetAccessTokenIfTokenIsStillValidAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + DateTimeOffset expiresAtUtc = randomDateTimeOffset.AddMinutes(30); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((randomAccessToken, expiresAtUtc)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldReturnEmptyStringOnGetAccessTokenIfRefreshTokenIsMissingAsync() + { + // given + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldReturnEmptyStringOnGetAccessTokenIfRefreshTokenHasExpiredAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + DateTimeOffset expiredAtUtc = randomDateTimeOffset.AddMinutes(-1); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, expiredAtUtc)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldRefreshAccessTokenOnGetAccessTokenIfStoredTokenHasExpiredAsync() + { + // given + string randomRefreshToken = GetRandomString(); + string expiredAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult, + storedAccessToken: expiredAccessToken, + storedAccessExpiresAtUtc: randomDateTimeOffset.AddSeconds(-1)); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomTokenResult.AccessToken); + actualAccessToken.Should().NotBe(expiredAccessToken); + } + + [Fact] + public async Task ShouldRefreshAccessTokenOnGetAccessTokenIfStoredTokenExpiresInsideTheRefreshSkewAsync() + { + // given + string randomRefreshToken = GetRandomString(); + string nearlyExpiredAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + // The service refreshes anything expiring within 60 seconds rather than handing back a token + // that will die mid-request. + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult, + storedAccessToken: nearlyExpiredAccessToken, + storedAccessExpiresAtUtc: randomDateTimeOffset.AddSeconds(59)); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomTokenResult.AccessToken); + actualAccessToken.Should().NotBe(nearlyExpiredAccessToken); + } + + [Fact] + public async Task ShouldReturnStoredAccessTokenOnGetAccessTokenIfItOutlivesTheRefreshSkewAsync() + { + // given + string storedAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((storedAccessToken, randomDateTimeOffset.AddSeconds(61))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(storedAccessToken); + + this.tokenBrokerMock.Verify(broker => + broker.GetRefreshTokenAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldStoreRefreshedAccessTokenOnGetAccessTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.ExpiresIn)); + + SetupExpiredAccessTokenWithValidRefreshToken(randomRefreshToken, randomDateTimeOffset, randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreAccessTokenAsync( + randomTokenResult.AccessToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + private void SetupExpiredAccessTokenWithValidRefreshToken( + string refreshToken, + DateTimeOffset currentDateTimeOffset, + TokenResult refreshedTokenResult, + string storedAccessToken = null, + DateTimeOffset? storedAccessExpiresAtUtc = null) + { + string randomTokenJson = GetRandomString(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((storedAccessToken, storedAccessExpiresAtUtc)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((refreshToken, currentDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(currentDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + this.apiPlatformConfigurations.CareIdentity.TokenEndpoint, + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(refreshedTokenResult); + } + + [Fact] + public async Task ShouldStoreTheRotatedRefreshTokenOnGetAccessTokenAsync() + { + // given + // CIS2 rotates the refresh token on every refresh. If the SDK fails to store the new one, + // the next refresh presents a spent token and the user is silently signed out. + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + DateTimeOffset expectedRefreshExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.RefreshTokenExpiresIn)); + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + randomTokenResult.RefreshToken, + expectedRefreshExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreARefreshTokenOnGetAccessTokenIfTheRefreshDidNotReturnOneAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + randomTokenResult.RefreshToken = string.Empty; + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs new file mode 100644 index 0000000..425b0ef --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs @@ -0,0 +1,75 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldReturnUserInfoOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + + [Fact] + public async Task ShouldCallUserInfoEndpointOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs new file mode 100644 index 0000000..4b78462 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs @@ -0,0 +1,66 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldClearCsrfStateOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearAccessTokenOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearAccessTokenAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearRefreshTokenOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearRefreshTokenAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearActiveRoleOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearActiveRoleAsync(It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs new file mode 100644 index 0000000..7ad6286 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs @@ -0,0 +1,192 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnLogoutIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTaskCanceledExceptionOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + var taskCanceledException = new TaskCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(taskCanceledException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfOperationCanceledOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnCallbackIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + private static CareIdentityServiceDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs new file mode 100644 index 0000000..bc8265a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs @@ -0,0 +1,283 @@ +// --------------------------------------------------------- +// 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 Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnCallbackIfCodeIsInvalidAsync(string invalidCode) + { + // given + string randomState = GetRandomString(); + + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "code", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask callbackTask = + this.careIdentityService.CallbackAsync(invalidCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnCallbackIfStateIsInvalidAsync(string invalidState) + { + // given + string randomCode = GetRandomString(); + + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "state", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask callbackTask = + this.careIdentityService.CallbackAsync(randomCode, invalidState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfAccessTokenIsInvalidAsync( + string invalidAccessToken) + { + // given + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "accessToken", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(invalidAccessToken, default); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnCallbackIfStateDoesNotMatchStoredStateAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string differentState = GetRandomString(); + + var invalidStateCareIdentityServiceException = + new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidStateCareIdentityServiceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync(differentState); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnCallbackIfNoStateWasStoredAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var invalidStateCareIdentityServiceException = + new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidStateCareIdentityServiceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync((string)null); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnGetAccessTokenIfRefreshedTokenIsEmptyAsync() + { + // given + string randomRefreshToken = GetRandomString(); + string randomTokenJson = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult emptyTokenResult = CreateRandomTokenResult(); + emptyTokenResult.AccessToken = string.Empty; + + var unauthorisedCareIdentityServiceException = + new UnauthorisedCareIdentityServiceException( + message: "Authentication failed (no access token)."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: unauthorisedCareIdentityServiceException); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, randomDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(emptyTokenResult); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs new file mode 100644 index 0000000..9ca1e06 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs @@ -0,0 +1,150 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Cryptographies; +using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; +using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; +using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + private readonly Mock httpBrokerMock; + private readonly Mock jsonBrokerMock; + private readonly Mock cryptoBrokerMock; + private readonly Mock dateTimeBrokerMock; + private readonly Mock stateBrokerMock; + private readonly Mock tokenBrokerMock; + private readonly Mock loggingBrokerMock; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly ICareIdentityService careIdentityService; + + public CareIdentityServiceTests() + { + this.httpBrokerMock = new Mock(); + this.jsonBrokerMock = new Mock(); + this.cryptoBrokerMock = new Mock(); + this.dateTimeBrokerMock = new Mock(); + this.stateBrokerMock = new Mock(); + this.tokenBrokerMock = new Mock(); + this.loggingBrokerMock = new Mock(); + this.apiPlatformConfigurations = CreateRandomConfigurations(); + + this.careIdentityService = new CareIdentityService( + configurations: this.apiPlatformConfigurations, + httpBroker: this.httpBrokerMock.Object, + jsonBroker: this.jsonBrokerMock.Object, + cryptoBroker: this.cryptoBrokerMock.Object, + dateTimeBroker: this.dateTimeBrokerMock.Object, + stateBroker: this.stateBrokerMock.Object, + tokenBroker: this.tokenBrokerMock.Object, + loggingBroker: this.loggingBrokerMock.Object); + } + + public static TheoryData DependencyExceptions() => + new TheoryData + { + new HttpRequestException(), + new TimeoutException() + }; + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static ApiPlatformConfigurations CreateRandomConfigurations() => + new ApiPlatformConfigurations + { + CareIdentity = new CareIdentityConfigurations + { + ClientId = GetRandomString(), + ClientSecret = GetRandomString(), + RedirectUri = $"https://{GetRandomString()}/callback", + AuthEndpoint = $"https://{GetRandomString()}/authorize", + TokenEndpoint = $"https://{GetRandomString()}/token", + UserInfoEndpoint = $"https://{GetRandomString()}/userinfo", + AcrValues = null + }, + + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"https://{GetRandomString()}/fhir" + } + }; + + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static int GetRandomNumber() => + new IntRange(min: 100, max: 900).GetValue(); + + private static DateTimeOffset GetRandomDateTimeOffset() => + new DateTimeRange(earliestDate: new DateTime()).GetValue(); + + private static HttpResponseMessage CreateHttpResponse(string content) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + + private static TokenResult CreateRandomTokenResult() => + new TokenResult + { + AccessToken = GetRandomString(), + RefreshToken = GetRandomString(), + TokenType = "Bearer", + ExpiresIn = GetRandomNumber().ToString(), + RefreshTokenExpiresIn = GetRandomNumber().ToString() + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List + { + new NhsNrbacRole + { + PersonRoleId = GetRandomString(), + PersonOrgId = GetRandomString(), + OrgCode = GetRandomString(), + RoleName = GetRandomString(), + RoleCode = GetRandomString() + } + } + }; + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..f98a324 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs @@ -0,0 +1,113 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask searchPatientsTask = this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + this.httpBrokerMock.VerifyNoOtherCalls(); + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + // The token is live at entry so ThrowIfCancellationRequested lets us through; the broker is what + // cancels, which is the path a caller aborting an in-flight request actually takes. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .Throws(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToBrokersOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationToken); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + cancellationToken), + Times.Once); + + this.tokenBrokerMock.Verify(broker => + broker.GetActiveRoleAsync(cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs new file mode 100644 index 0000000..6d44266 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs @@ -0,0 +1,206 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + + [Fact] + public async Task ShouldEscapeDemographicSearchValuesOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string actualUrl = null; + + var searchCriteria = new SearchCriteria + { + Surname = "O'Brien & Sons", + FirstName = "Anne Marie", + Gender = "female/other", + DateOfBirth = "1980-01-01 00:00", + Postcode = "E1 6AN" + }; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => actualUrl = url) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, searchCriteria); + + // then + actualUrl.Should().Contain($"family={Uri.EscapeDataString("O'Brien & Sons")}"); + actualUrl.Should().Contain($"given={Uri.EscapeDataString("Anne Marie")}"); + actualUrl.Should().Contain($"gender={Uri.EscapeDataString("female/other")}"); + actualUrl.Should().Contain($"birthdate=eq{Uri.EscapeDataString("1980-01-01 00:00")}"); + actualUrl.Should().Contain($"address-postalcode={Uri.EscapeDataString("E1 6AN")}"); + + // The raw ampersand would otherwise have introduced a query parameter of its own. + actualUrl.Should().NotContain("& Sons"); + actualUrl.Should().NotContain("Anne Marie"); + + // An unescaped "/" in gender would have introduced a path segment. + actualUrl.Should().NotContain("female/other"); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfPdsRejectsTheRequestAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.InnerException.Should().BeOfType(); + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfPdsFailsAsync(HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.InnerException.Should().BeOfType(); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("../../Practitioner/1")] + [InlineData("12345")] + [InlineData("abcdefghij")] + [InlineData("123456789 ")] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberIsNotTenDigitsAsync( + string invalidNhsNumber) + { + // given + string randomAccessToken = GetRandomString(); + var searchCriteria = new SearchCriteria { NhsNumber = invalidNhsNumber }; + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "searchCriteria.NhsNumber", + value: "NHS number must be 10 digits"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, searchCriteria); + + PdsServiceValidationException actualException = + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldAssertTheDependencyValidationMessageOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(GetRandomString()) + }); + + // when + PdsServiceDependencyValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria)); + + // then + actualException.Message + .Should().Be("PDS service dependency validation error occurred, fix the errors and try again."); + + actualException.InnerException.Message + .Should().Be("Invalid PDS service dependency error occurred, fix the errors and try again."); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..4300a50 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs @@ -0,0 +1,124 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + PdsServiceException expectedPdsServiceException = + CreateExpectedServiceException(serviceException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(serviceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceException actualPdsServiceException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceException.Should().BeEquivalentTo(expectedPdsServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfResponseIsUnsuccessfulAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + var unsuccessfulResponse = + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent(GetRandomString()) + }; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(unsuccessfulResponse); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException.InnerException.InnerException + .Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..38ccbdd --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs @@ -0,0 +1,193 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldReturnPatientPayloadOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomPayload = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomPayload)); + + // when + string actualPayload = await this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + } + + [Fact] + public async Task ShouldSearchByNhsNumberOnSearchPatientsIfNhsNumberIsSuppliedAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + string expectedUrl = + $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}" + + $"/Patient/{randomSearchCriteria.NhsNumber}"; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + expectedUrl, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldSearchByDemographicsOnSearchPatientsIfNhsNumberIsMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithDemographics(); + + string expectedUrl = + $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}" + + $"/Patient?family={Uri.EscapeDataString(randomSearchCriteria.Surname)}" + + $"&given={Uri.EscapeDataString(randomSearchCriteria.FirstName)}" + + $"&gender={Uri.EscapeDataString(randomSearchCriteria.Gender)}" + + $"&birthdate=eq{randomSearchCriteria.DateOfBirth}" + + $"&address-postalcode={Uri.EscapeDataString(randomSearchCriteria.Postcode)}"; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + expectedUrl, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldRetrieveActiveRoleOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.tokenBrokerMock.Verify(broker => + broker.GetActiveRoleAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldAddAuthorisationAndTracingHeadersOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomActiveRoleId = GetRandomString(); + Guid randomRequestId = Guid.NewGuid(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/patient"); + + this.tokenBrokerMock.Setup(broker => + broker.GetActiveRoleAsync(It.IsAny())) + .ReturnsAsync(randomActiveRoleId); + + this.identifierBrokerMock.Setup(broker => + broker.GetNewGuid()) + .Returns(randomRequestId); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + actualRequest.Headers.Authorization.Parameter.Should().Be(randomAccessToken); + actualRequest.Headers.GetValues("X-Request-ID").Should().Contain(randomRequestId.ToString()); + actualRequest.Headers.GetValues("NHSD-Session-URID").Should().Contain(randomActiveRoleId); + } + + [Fact] + public async Task ShouldNotAddSessionRoleHeaderOnSearchPatientsIfActiveRoleIsMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/patient"); + + this.tokenBrokerMock.Setup(broker => + broker.GetActiveRoleAsync(It.IsAny())) + .ReturnsAsync((string)null); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + actualRequest.Headers.Contains("NHSD-Session-URID").Should().BeFalse(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs new file mode 100644 index 0000000..85a2f44 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs @@ -0,0 +1,107 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var operationCanceledException = new OperationCanceledException(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfTaskCanceledExceptionOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var taskCanceledException = new TaskCanceledException(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(taskCanceledException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); + } + + private static PdsServiceDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs new file mode 100644 index 0000000..fff1044 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs @@ -0,0 +1,134 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria nullSearchCriteria = null; + + var nullSearchCriteriaPdsServiceException = + new NullSearchCriteriaPdsServiceException( + message: "Search criteria is null."); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: nullSearchCriteriaPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, nullSearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfAccessTokenIsInvalidAsync( + string invalidAccessToken) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "accessToken", + value: "Text is required"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(invalidAccessToken, randomSearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberAndSurnameAreMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + var emptySearchCriteria = new SearchCriteria(); + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "searchCriteria", + value: "Either an NHS number or a surname is required"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, emptySearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs new file mode 100644 index 0000000..d4af66c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs @@ -0,0 +1,141 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + private readonly Mock httpBrokerMock; + private readonly Mock identifierBrokerMock; + private readonly Mock tokenBrokerMock; + private readonly Mock loggingBrokerMock; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly IPdsService pdsService; + + public PdsServiceTests() + { + this.httpBrokerMock = new Mock(); + this.identifierBrokerMock = new Mock(); + this.tokenBrokerMock = new Mock(); + this.loggingBrokerMock = new Mock(); + + this.apiPlatformConfigurations = new ApiPlatformConfigurations + { + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"https://{GetRandomString()}/fhir" + } + }; + + this.pdsService = new PdsService( + configurations: this.apiPlatformConfigurations, + httpBroker: this.httpBrokerMock.Object, + identifierBroker: this.identifierBrokerMock.Object, + tokenBroker: this.tokenBrokerMock.Object, + loggingBroker: this.loggingBrokerMock.Object); + } + + public static TheoryData DependencyExceptions() => + new TheoryData + { + new HttpRequestException(), + new TimeoutException() + }; + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static SearchCriteria CreateRandomSearchCriteriaWithNhsNumber() => + new SearchCriteria + { + NhsNumber = GetRandomNhsNumber() + }; + + private static SearchCriteria CreateRandomSearchCriteriaWithDemographics() => + new SearchCriteria + { + Surname = GetRandomString(), + FirstName = GetRandomString(), + Gender = GetRandomString(), + DateOfBirth = "1980-01-01", + Postcode = GetRandomString() + }; + + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + + private static string GetRandomNhsNumber() => + new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString(); + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static HttpResponseMessage CreateHttpResponse(string content) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + + private static PdsServiceDependencyException CreateExpectedDependencyException( + Exception dependencyException) + { + Xeption expectedInnerException = dependencyException is TimeoutException + ? new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: dependencyException, + data: dependencyException.Data) + + : new FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: dependencyException); + + return new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: expectedInnerException); + } + + private static PdsServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedPdsServiceException = + new FailedPdsServiceException( + message: "Failed PDS service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new PdsServiceException( + message: "PDS service error occurred, please contact support.", + innerException: failedPdsServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..0f88e1a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -0,0 +1,153 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( + randomSearchCriteria, + cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.pdsServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionRaisedByADependencyOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + // The token is live when the call starts, so ThrowIfCancellationRequested lets us through and the + // dependency itself is the one that raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldSurfaceAFoundationTimeoutAsDependencyExceptionOnSearchPatientsAndLogItAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var pdsServiceDependencyException = + new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + + var expectedException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(GetRandomString()); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(pdsServiceDependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToDependenciesOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + string randomAccessToken = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria, cancellationToken); + + // then + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(cancellationToken), + Times.Once); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync(randomAccessToken, randomSearchCriteria, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..a6154e1 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -0,0 +1,118 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedPdsOrchestrationDependencyValidationException = + new PdsOrchestrationDependencyValidationException( + message: "PDS orchestration dependency validation error occurred, fix the errors and try again.", + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationDependencyValidationException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedPdsOrchestrationDependencyException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, please contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationDependencyException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + PdsOrchestrationServiceException expectedPdsOrchestrationServiceException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationServiceException actualPdsOrchestrationServiceException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationServiceException + .Should().BeEquivalentTo(expectedPdsOrchestrationServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationServiceException))), + Times.Once); + } + + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..76914d8 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldReturnPatientPayloadOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomPayload = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + It.IsAny())) + .ReturnsAsync(randomPayload); + + // when + string actualPayload = + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + } + + [Fact] + public async Task ShouldRetrieveAccessTokenBeforeSearchingPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + // then + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs new file mode 100644 index 0000000..161c341 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs @@ -0,0 +1,132 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + SearchCriteria nullSearchCriteria = null; + + var nullSearchCriteriaPdsOrchestrationException = + new NullSearchCriteriaPdsOrchestrationException( + message: "Search criteria is null."); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: nullSearchCriteriaPdsOrchestrationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(nullSearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); + + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberAndSurnameAreMissingAsync() + { + // given + var emptySearchCriteria = new SearchCriteria(); + + var invalidArgumentPdsOrchestrationException = + new InvalidArgumentPdsOrchestrationException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsOrchestrationException.UpsertDataList( + key: "searchCriteria", + value: "Either an NHS number or a surname is required"); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: invalidArgumentPdsOrchestrationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(emptySearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfAccessTokenIsUnavailableAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var unauthorizedPdsOrchestrationException = + new UnauthorizedPdsOrchestrationException( + message: "Unauthorized - Unable to retrieve access token."); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: unauthorizedPdsOrchestrationException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(string.Empty); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs new file mode 100644 index 0000000..bc1917d --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs @@ -0,0 +1,100 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Linq.Expressions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + private readonly Mock careIdentityServiceMock; + private readonly Mock pdsServiceMock; + private readonly Mock loggingBrokerMock; + private readonly IPdsOrchestrationService pdsOrchestrationService; + + public PdsOrchestrationServiceTests() + { + this.careIdentityServiceMock = new Mock(); + this.pdsServiceMock = new Mock(); + this.loggingBrokerMock = new Mock(); + + this.pdsOrchestrationService = new PdsOrchestrationService( + careIdentityService: this.careIdentityServiceMock.Object, + pdsService: this.pdsServiceMock.Object, + loggingBroker: this.loggingBrokerMock.Object); + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceValidationException(GetRandomString(), innerException), + new CareIdentityServiceDependencyValidationException(GetRandomString(), innerException), + new PdsServiceValidationException(GetRandomString(), innerException), + new PdsServiceDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceDependencyException(GetRandomString(), innerException), + new CareIdentityServiceServiceException(GetRandomString(), innerException), + new PdsServiceDependencyException(GetRandomString(), innerException), + new PdsServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + private static SearchCriteria CreateRandomSearchCriteria() => + new SearchCriteria + { + NhsNumber = new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString() + }; + + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static PdsOrchestrationServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedPdsOrchestrationException = + new FailedPdsOrchestrationException( + message: "Failed PDS orchestration service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new PdsOrchestrationServiceException( + message: "PDS orchestration service error occurred, please contact support.", + innerException: failedPdsOrchestrationException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs new file mode 100644 index 0000000..7feb99f --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs @@ -0,0 +1,196 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask logoutTask = + this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await logoutTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync( + randomCode, + randomState, + cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getUserInfoTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldRethrowOperationCanceledExceptionRaisedByTheFoundationOnGetAccessTokenAsync() + { + // given + // The token is live when the call starts, so ThrowIfCancellationRequested lets us through and the + // dependency itself is the one that raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldSurfaceAFoundationTimeoutAsDependencyExceptionOnBuildLoginUrlAndLogItAsync() + { + // given + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var careIdentityServiceDependencyException = + new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(careIdentityServiceDependencyException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToFoundationServiceOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceProcessingService.GetUserInfoAsync( + randomCode, + randomState, + cancellationToken); + + // then + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync(randomCode, randomState, cancellationToken), + Times.Once); + + this.careIdentityServiceMock.Verify(service => + service.GetUserInfoAsync(randomAccessToken, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs new file mode 100644 index 0000000..c7f55fb --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs @@ -0,0 +1,170 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowDependencyValidationExceptionOnBuildLoginUrlIfDependencyValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyValidationException( + message: "Care identity service processing dependency validation error occurred, " + + "please fix the errors and try again.", + + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnBuildLoginUrlIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceProcessingServiceException expectedException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingServiceException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnLogoutIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnLogoutIfServiceErrorOccursAsync(Exception serviceException) + { + // given + CareIdentityServiceProcessingServiceException expectedException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + CareIdentityServiceProcessingServiceException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs new file mode 100644 index 0000000..a5d5eac --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs @@ -0,0 +1,117 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomLoginUrl = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ReturnsAsync(randomLoginUrl); + + // when + string actualLoginUrl = await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(randomLoginUrl); + + this.careIdentityServiceMock.Verify(service => + service.BuildLoginUrlAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldLogoutAsync() + { + // given + // when + await this.careIdentityServiceProcessingService.LogoutAsync(); + + // then + this.careIdentityServiceMock.Verify(service => + service.LogoutAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldGetAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + // when + string actualAccessToken = await this.careIdentityServiceProcessingService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldReturnUserInfoOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(randomAccessToken, It.IsAny())) + .ReturnsAsync(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + + [Fact] + public async Task ShouldCompleteCallbackBeforeRetrievingUserInfoOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + // then + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync(randomCode, randomState, It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs new file mode 100644 index 0000000..306664b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs @@ -0,0 +1,144 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfCodeIsInvalidAsync(string invalidCode) + { + // given + string randomState = GetRandomString(); + + var invalidArgumentCareIdentityServiceProcessingException = + new InvalidArgumentCareIdentityServiceProcessingException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceProcessingException.UpsertDataList( + key: "code", + value: "Text is required"); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceProcessingException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(invalidCode, randomState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfStateIsInvalidAsync(string invalidState) + { + // given + string randomCode = GetRandomString(); + + var invalidArgumentCareIdentityServiceProcessingException = + new InvalidArgumentCareIdentityServiceProcessingException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceProcessingException.UpsertDataList( + key: "state", + value: "Text is required"); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceProcessingException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, invalidState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfAccessTokenIsUnavailableAsync( + string invalidAccessToken) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var unauthorisedCareIdentityServiceProcessingException = + new UnauthorisedCareIdentityServiceProcessingException( + message: "Authentication failed (no access token)."); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: unauthorisedCareIdentityServiceProcessingException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(invalidAccessToken); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + + this.careIdentityServiceMock.Verify(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs new file mode 100644 index 0000000..96a3bce --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs @@ -0,0 +1,105 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + private readonly Mock careIdentityServiceMock; + private readonly Mock loggingBrokerMock; + private readonly ICareIdentityServiceProcessingService careIdentityServiceProcessingService; + + public CareIdentityServiceProcessingServiceTests() + { + this.careIdentityServiceMock = new Mock(); + this.loggingBrokerMock = new Mock(); + + this.careIdentityServiceProcessingService = + new CareIdentityServiceProcessingService( + careIdentityService: this.careIdentityServiceMock.Object, + loggingBroker: this.loggingBrokerMock.Object); + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceValidationException(GetRandomString(), innerException), + new CareIdentityServiceDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceDependencyException(GetRandomString(), innerException), + new CareIdentityServiceServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List() + }; + + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static CareIdentityServiceProcessingServiceException CreateExpectedServiceException( + Exception serviceException) + { + var failedCareIdentityServiceProcessingException = + new FailedCareIdentityServiceProcessingException( + message: "Failed care identity service processing error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new CareIdentityServiceProcessingServiceException( + message: "Care identity service processing error occurred, please contact support.", + innerException: failedCareIdentityServiceProcessingException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs new file mode 100644 index 0000000..140d409 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; + +namespace NHSDigital.ApiPlatform.Sdk.Brokers.Loggings +{ + public interface ILoggingBroker + { + ValueTask LogErrorAsync(Exception exception); + ValueTask LogCriticalAsync(Exception exception); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs new file mode 100644 index 0000000..afda747 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs @@ -0,0 +1,32 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace NHSDigital.ApiPlatform.Sdk.Brokers.Loggings +{ + internal sealed class LoggingBroker : ILoggingBroker + { + private readonly ILogger logger; + + public LoggingBroker(ILogger logger) => + this.logger = logger; + + public ValueTask LogErrorAsync(Exception exception) + { + this.logger.LogError(exception, exception.Message); + + return ValueTask.CompletedTask; + } + + public ValueTask LogCriticalAsync(Exception exception) + { + this.logger.LogCritical(exception, exception.Message); + + return ValueTask.CompletedTask; + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs index e40a863..6df3586 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -26,6 +26,10 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation { return await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -34,7 +38,7 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -63,6 +67,10 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default { await this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -71,7 +79,7 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -100,6 +108,10 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio { return await this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -108,7 +120,7 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -121,6 +133,14 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio throw CreateCareIdentityServiceClientServiceException( careIdentityServiceProcessingServiceException.InnerException as Xeption); } + catch (Exception exception) + { + throw CreateCareIdentityServiceClientServiceException( + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: exception, + data: exception.Data)); + } } public async ValueTask GetUserInfoAsync( @@ -132,6 +152,10 @@ public async ValueTask GetUserInfoAsync( { return await this.careIdentityServiceProcessingService.GetUserInfoAsync(code, state, cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -140,7 +164,7 @@ public async ValueTask GetUserInfoAsync( catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -153,6 +177,14 @@ public async ValueTask GetUserInfoAsync( throw CreateCareIdentityServiceClientServiceException( careIdentityServiceProcessingServiceException.InnerException as Xeption); } + catch (Exception exception) + { + throw CreateCareIdentityServiceClientServiceException( + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: exception, + data: exception.Data)); + } } private static CareIdentityServiceClientValidationException @@ -163,6 +195,15 @@ private static CareIdentityServiceClientValidationException innerException); } + private static CareIdentityServiceClientDependencyValidationException + CreateCareIdentityServiceClientDependencyValidationException(Xeption innerException) + { + return new CareIdentityServiceClientDependencyValidationException( + message: "Care identity service client dependency validation error occurred, " + + "fix errors and try again.", + innerException); + } + private static CareIdentityServiceClientDependencyException CreateCareIdentityServiceClientDependencyException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index 9b18562..4aea33a 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -23,15 +23,19 @@ public PersonalDemographicsServiceClient(IPdsOrchestrationService pdsOrchestrati this.pdsOrchestrationService = pdsOrchestrationService; public async ValueTask SearchPatientsAsync( - SearchCriteria searchCriteria, + SearchCriteria searchCriteria, CancellationToken cancellationToken = default) { try { return await this.pdsOrchestrationService.SearchPatientsAsync( - searchCriteria, + searchCriteria, cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (PdsOrchestrationValidationException pdsOrchestrationValidationException) { throw CreatePersonalDemographicsServiceClientValidationException( @@ -40,7 +44,7 @@ public async ValueTask SearchPatientsAsync( catch (PdsOrchestrationDependencyValidationException pdsOrchestrationDependencyValidationException) { - throw CreatePersonalDemographicsServiceClientValidationException( + throw CreatePersonalDemographicsServiceClientDependencyValidationException( pdsOrchestrationDependencyValidationException.InnerException as Xeption); } catch (PdsOrchestrationDependencyException pdsOrchestrationDependencyException) @@ -71,6 +75,15 @@ private static PersonalDemographicsServiceClientValidationException innerException); } + private static PersonalDemographicsServiceClientDependencyValidationException + CreatePersonalDemographicsServiceClientDependencyValidationException(Xeption innerException) + { + return new PersonalDemographicsServiceClientDependencyValidationException( + message: "Personal demographics service client dependency validation error occurred, " + + "fix errors and try again.", + innerException); + } + private static PersonalDemographicsServiceClientDependencyException CreatePersonalDemographicsServiceClientDependencyException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs new file mode 100644 index 0000000..9046438 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class FailedCareIdentityServiceDependencyException : Xeption + { + public FailedCareIdentityServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs new file mode 100644 index 0000000..766396b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class InvalidCareIdentityServiceDependencyException : Xeption + { + public InvalidCareIdentityServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs new file mode 100644 index 0000000..5d93471 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class InvalidStateCareIdentityServiceException : Xeption + { + public InvalidStateCareIdentityServiceException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs new file mode 100644 index 0000000..185d390 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class TimeoutCareIdentityServiceException : Xeption + { + public TimeoutCareIdentityServiceException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs new file mode 100644 index 0000000..1418cb0 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class FailedPdsServiceDependencyException : Xeption + { + public FailedPdsServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs new file mode 100644 index 0000000..db6441a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class InvalidPdsServiceDependencyException : Xeption + { + public InvalidPdsServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs new file mode 100644 index 0000000..af71475 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class NullSearchCriteriaPdsServiceException : Xeption + { + public NullSearchCriteriaPdsServiceException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs new file mode 100644 index 0000000..515993b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class TimeoutPdsServiceException : Xeption + { + public TimeoutPdsServiceException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs new file mode 100644 index 0000000..8b8a17e --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions +{ + public class NullSearchCriteriaPdsOrchestrationException : Xeption + { + public NullSearchCriteriaPdsOrchestrationException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj index a15382b..8ebff08 100644 --- a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj +++ b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -19,12 +19,18 @@ git NHSISL; NHS Digital; API; Platform; SDK; Client; .NET; The Standard; - Updated package version to 0.2.0.3. + Release 0.3.0.0 - BREAKING. + Dependency failures are now categorised: a 4xx from CIS2 or PDS raises a + DependencyValidation exception, a 5xx or transport failure raises a Dependency exception, + and a dependency timeout raises a Dependency exception wrapping Timeout*Exception. + Cancellation now propagates to callers as OperationCanceledException instead of being + wrapped. Services take an additional ILoggingBroker constructor argument. + See the 0.3.0.0 release notes for the full upgrade guide. True - 0.2.0.3 - 0.2.0.3 - 0.2.0.3 + 0.3.0.0 + 0.3.0.0 + 0.3.0.0 README.md LICENSE.txt true @@ -50,14 +56,16 @@ - + + + diff --git a/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs b/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs index 56e281f..c41ddd8 100644 --- a/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs +++ b/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; @@ -33,6 +34,8 @@ public static IServiceCollection AddApiPlatformSdkCore( services.AddSingleton(); services.AddSingleton(); services.AddHttpClient("NhsApiPlatform"); + services.AddLogging(); + services.TryAddSingleton(); services.AddTransient(); services.AddScoped(); services.AddScoped(); diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index c4c8991..6f3313d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -1,8 +1,11 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Net; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using Xeptions; @@ -14,75 +17,252 @@ internal partial class CareIdentityService private delegate ValueTask ReturningTaskFunction(); private delegate ValueTask ReturningNothingFunction(); - private async ValueTask TryCatch(ReturningTaskFunction returningTaskFunction) + private async ValueTask TryCatch( + ReturningTaskFunction returningTaskFunction, + CancellationToken cancellationToken) { try { return await returningTaskFunction(); } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceException invalidArgumentCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentCareIdentityServiceException); } catch (UnauthorisedCareIdentityServiceException unauthorisedCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(unauthorisedCareIdentityServiceException); + } + catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) + { + throw await CreateAndLogValidationExceptionAsync(invalidStateCareIdentityServiceException); + } + catch (CareIdentityServiceValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyException) + { + throw; + } + catch (CareIdentityServiceServiceException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); + } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidCareIdentityServiceDependencyException = + new InvalidCareIdentityServiceDependencyException( + message: "Invalid care identity service dependency error occurred, " + + "fix the errors and try again.", + + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync( + invalidCareIdentityServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateAndLogDependencyExceptionAsync(failedCareIdentityServiceDependencyException); } catch (Exception exception) { - var failedPatientServiceException = + var failedCareIdentityServiceException = new FailedCareIdentityServiceException( message: "Failed care identity service error occurred, please contact support.", innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPatientServiceException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceException); } } - private async ValueTask TryCatch(ReturningNothingFunction returningNothingFunction) + private async ValueTask TryCatch( + ReturningNothingFunction returningNothingFunction, + CancellationToken cancellationToken) { try { await returningNothingFunction(); } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceException invalidArgumentCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentCareIdentityServiceException); } catch (UnauthorisedCareIdentityServiceException unauthorisedCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(unauthorisedCareIdentityServiceException); + } + catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) + { + throw await CreateAndLogValidationExceptionAsync(invalidStateCareIdentityServiceException); + } + catch (CareIdentityServiceValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyException) + { + throw; + } + catch (CareIdentityServiceServiceException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); + } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidCareIdentityServiceDependencyException = + new InvalidCareIdentityServiceDependencyException( + message: "Invalid care identity service dependency error occurred, " + + "fix the errors and try again.", + + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync( + invalidCareIdentityServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateAndLogDependencyExceptionAsync(failedCareIdentityServiceDependencyException); } catch (Exception exception) { - var failedPatientServiceException = + var failedCareIdentityServiceException = new FailedCareIdentityServiceException( message: "Failed care identity service error occurred, please contact support.", innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPatientServiceException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceException); } } - private async ValueTask CreateValidationExceptionAsync( + private async ValueTask + CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); + } + + private async ValueTask CreateAndLogValidationExceptionAsync( Xeption exception) { var careIdentityServiceValidationException = new CareIdentityServiceValidationException( message: "Care identity service validation error occurred, please fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceValidationException); + return careIdentityServiceValidationException; } - private async ValueTask CreateServiceExceptionAsync( + // A 4xx tells us the dependency rejected what we sent it, which the caller can act on. + // A 5xx (or a transport failure, where StatusCode is null) is not the caller's to fix. + private static bool IsDependencyValidationStatusCode(HttpStatusCode? statusCode) => + statusCode >= HttpStatusCode.BadRequest && statusCode < HttpStatusCode.InternalServerError; + + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) + { + var careIdentityServiceDependencyValidationException = + new CareIdentityServiceDependencyValidationException( + message: "Care identity service dependency validation error occurred, " + + "fix the errors and try again.", + + innerException: exception); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceDependencyValidationException); + + return careIdentityServiceDependencyValidationException; + } + + private async ValueTask CreateAndLogDependencyExceptionAsync( + Xeption exception) + { + var careIdentityServiceDependencyException = new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceDependencyException); + + return careIdentityServiceDependencyException; + } + + private async ValueTask CreateAndLogServiceExceptionAsync( Xeption exception) { var careIdentityServiceServiceException = new CareIdentityServiceServiceException( message: "Care identity service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceServiceException); + return careIdentityServiceServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs index 2460f20..85dec74 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs @@ -28,6 +28,16 @@ public void ValidateOnExchangeCodeForToken(string code) (Rule: IsInvalid(code), Parameter: nameof(code))); } + public void ValidateStateMatches(string state, string expectedState) + { + if (string.IsNullOrWhiteSpace(expectedState) || + string.Equals(state, expectedState, StringComparison.Ordinal) is false) + { + throw new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + } + } + public void ValidateAccessToken(string accessToken) { if (string.IsNullOrWhiteSpace(accessToken)) diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index 8f14f6d..9cb9424 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; @@ -8,6 +8,7 @@ using NHSDigital.ApiPlatform.Sdk.Brokers.Cryptographies; using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; @@ -24,6 +25,7 @@ internal sealed partial class CareIdentityService : ICareIdentityService private readonly IDateTimeBroker dateTimeBroker; private readonly IApiPlatformStateBroker stateBroker; private readonly IApiPlatformTokenBroker tokenBroker; + private readonly ILoggingBroker loggingBroker; public CareIdentityService( ApiPlatformConfigurations configurations, @@ -32,8 +34,10 @@ public CareIdentityService( ICryptoBroker cryptoBroker, IDateTimeBroker dateTimeBroker, IApiPlatformStateBroker stateBroker, - IApiPlatformTokenBroker tokenBroker) + IApiPlatformTokenBroker tokenBroker, + ILoggingBroker loggingBroker) { + this.loggingBroker = loggingBroker; this.configurations = configurations; this.httpBroker = httpBroker; this.jsonBroker = jsonBroker; @@ -42,9 +46,11 @@ public CareIdentityService( this.stateBroker = stateBroker; this.tokenBroker = tokenBroker; } + public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); string csrfState = this.cryptoBroker.CreateUrlSafeState(); await this.stateBroker.StoreCsrfStateAsync(csrfState, cancellationToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -63,16 +69,17 @@ public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken } return url; - }); + }, cancellationToken); public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); await this.stateBroker.ClearCsrfStateAsync(cancellationToken); await this.tokenBroker.ClearAccessTokenAsync(cancellationToken); await this.tokenBroker.ClearRefreshTokenAsync(cancellationToken); await this.tokenBroker.ClearActiveRoleAsync(cancellationToken); - }); + }, cancellationToken); public ValueTask CallbackAsync( string code, @@ -80,16 +87,11 @@ public ValueTask CallbackAsync( CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnCallback(code, state); string? expectedState = await this.stateBroker.GetCsrfStateAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(expectedState) || - string.Equals(state, expectedState, StringComparison.Ordinal) is false) - { - throw new InvalidOperationException("Invalid state parameter."); - } - + ValidateStateMatches(state, expectedState); await this.stateBroker.ClearCsrfStateAsync(cancellationToken); TokenResult token = await ExchangeCodeForTokenAsync(code, cancellationToken); @@ -119,11 +121,13 @@ await this.tokenBroker.StoreActiveRoleAsync( userInfo.NhsIdNrbacRoles[0].PersonRoleId, cancellationToken); } - }); + }, cancellationToken); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + var (accessToken, accessExpiresAtUtc) = await this.tokenBroker.GetAccessTokenAsync(cancellationToken); @@ -171,11 +175,12 @@ await this.tokenBroker.StoreRefreshTokenAsync( ValidateAccessToken(refreshed.AccessToken); return refreshed.AccessToken; - }); + }, cancellationToken); public ValueTask GetUserInfoAsync(string accessToken, CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnGetUserInfo(accessToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -191,11 +196,12 @@ public ValueTask GetUserInfoAsync(string accessToken, CancellationT NhsUserInfo? userInfo = this.jsonBroker.Deserialize(json); return userInfo ?? throw new InvalidOperationException("UserInfo endpoint returned an invalid payload."); - }); + }, cancellationToken); private ValueTask ExchangeCodeForTokenAsync(string code, CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnExchangeCodeForToken(code); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -217,13 +223,14 @@ private ValueTask ExchangeCodeForTokenAsync(string code, Cancellati TokenResult? token = this.jsonBroker.Deserialize(json); return token ?? throw new InvalidOperationException("Token endpoint returned an invalid payload."); - }); + }, cancellationToken); private ValueTask ExchangeRefreshTokenForTokenAsync( string refreshToken, CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnExchangeRefreshTokenForToken(refreshToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -245,6 +252,6 @@ private ValueTask ExchangeRefreshTokenForTokenAsync( TokenResult? token = this.jsonBroker.Deserialize(json); return token ?? throw new InvalidOperationException("Token endpoint returned an invalid payload."); - }); + }, cancellationToken); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs index 7dc0fc3..ced7d33 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs @@ -3,6 +3,9 @@ // --------------------------------------------------------- using System; +using System.Net; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -13,17 +16,76 @@ internal partial class PdsService : IPdsService { private delegate ValueTask ReturningStringFunction(); - private async ValueTask TryCatch(ReturningStringFunction returningStringFunction) + private async ValueTask TryCatch( + ReturningStringFunction returningStringFunction, + CancellationToken cancellationToken) { try { return await returningStringFunction(); } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } + catch (NullSearchCriteriaPdsServiceException nullSearchCriteriaPdsServiceException) + { + throw await CreateAndLogValidationExceptionAsync(nullSearchCriteriaPdsServiceException); + } catch (InvalidArgumentPdsServiceException invalidArgumentPdsServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentPdsServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentPdsServiceException); + } + catch (PdsServiceValidationException) + { + throw; + } + catch (PdsServiceDependencyValidationException) + { + throw; + } + catch (PdsServiceDependencyException) + { + throw; + } + catch (PdsServiceException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogDependencyExceptionAsync(timeoutPdsServiceException); + } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidPdsServiceDependencyException = + new InvalidPdsServiceDependencyException( + message: "Invalid PDS service dependency error occurred, fix the errors and try again.", + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync(invalidPdsServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedPdsServiceDependencyException = + new FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateAndLogDependencyExceptionAsync(failedPdsServiceDependencyException); } - //TODO: Extend this to catch dependency and dependency validation exceptions. catch (Exception exception) { var failedPdsServiceException = @@ -32,26 +94,73 @@ private async ValueTask TryCatch(ReturningStringFunction returningString innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPdsServiceException); + throw await CreateAndLogServiceExceptionAsync(failedPdsServiceException); } } - private async ValueTask CreateValidationExceptionAsync( + private async ValueTask CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogDependencyExceptionAsync(timeoutPdsServiceException); + } + + private async ValueTask CreateAndLogValidationExceptionAsync( Xeption exception) { var pdsServiceValidationException = new PdsServiceValidationException( message: "PDS service validation error occurred, please fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsServiceValidationException); + return pdsServiceValidationException; } - private async ValueTask CreateServiceExceptionAsync(Xeption exception) + // A 4xx tells us the dependency rejected what we sent it, which the caller can act on. + // A 5xx (or a transport failure, where StatusCode is null) is not the caller's to fix. + private static bool IsDependencyValidationStatusCode(HttpStatusCode? statusCode) => + statusCode >= HttpStatusCode.BadRequest && statusCode < HttpStatusCode.InternalServerError; + + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) + { + var pdsServiceDependencyValidationException = new PdsServiceDependencyValidationException( + message: "PDS service dependency validation error occurred, fix the errors and try again.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(pdsServiceDependencyValidationException); + + return pdsServiceDependencyValidationException; + } + + private async ValueTask CreateAndLogDependencyExceptionAsync( + Xeption exception) + { + var pdsServiceDependencyException = new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(pdsServiceDependencyException); + + return pdsServiceDependencyException; + } + + private async ValueTask CreateAndLogServiceExceptionAsync(Xeption exception) { var pdsServiceException = new PdsServiceException( message: "PDS service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsServiceException); + return pdsServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs index c65f558..0857cd4 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs @@ -3,8 +3,8 @@ // --------------------------------------------------------- using System; -using System.Collections.Generic; -using System.Linq; +using System.Text.RegularExpressions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -12,41 +12,56 @@ namespace NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds { internal partial class PdsService : IPdsService { - public void ValidateOnSearchPatientsAsync( - string accessToken, - string family, - IEnumerable given, - string gender, - DateOnly? birthdate) + private static readonly Regex NhsNumberPattern = new Regex(@"^\d{10}$", RegexOptions.Compiled); + + public void ValidateOnSearchPatients(string accessToken, SearchCriteria searchCriteria) { + ValidateSearchCriteriaIsNotNull(searchCriteria); + Validate( createException: () => new InvalidArgumentPdsServiceException( message: "Invalid argument(s), please correct the errors and try again."), - (Rule: IsInvalid(family), Parameter: nameof(family)), - (Rule: IsInvalid(given), Parameter: nameof(given)), - (Rule: IsInvalid(gender), Parameter: nameof(gender)), - (Rule: IsInvalid(birthdate), Parameter: nameof(birthdate))); + (Rule: IsInvalid(accessToken), Parameter: nameof(accessToken)), + (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria)), + (Rule: IsInvalidNhsNumber(searchCriteria.NhsNumber), Parameter: "searchCriteria.NhsNumber")); + } + + private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteria) + { + if (searchCriteria is null) + { + throw new NullSearchCriteriaPdsServiceException( + message: "Search criteria is null."); + } } - private static dynamic IsInvalid(string? text) => new + private static dynamic IsInvalid(string text) => new { Condition = string.IsNullOrWhiteSpace(text), Message = "Text is required" }; - private static dynamic IsInvalid(DateOnly? dateOnly) => new + // An NHS number goes into the request PATH, so escaping alone is not enough: "." and ".." are + // unreserved characters that survive Uri.EscapeDataString, and Uri parsing then collapses the + // dot segments, silently moving the request off /Patient. Constraining it to the 10 digits an + // NHS number actually is closes that off at the source. + private static dynamic IsInvalidNhsNumber(string nhsNumber) => new { - Condition = dateOnly == null, - Message = "Date is required" + Condition = + string.IsNullOrWhiteSpace(nhsNumber) is false && + NhsNumberPattern.IsMatch(nhsNumber) is false, + + Message = "NHS number must be 10 digits" }; - private static dynamic IsInvalid(IEnumerable textList) => new + private static dynamic IsInvalidSearchCriteria(SearchCriteria searchCriteria) => new { - Condition = textList != null && - textList.Any(text => string.IsNullOrWhiteSpace(text)), + Condition = + string.IsNullOrWhiteSpace(searchCriteria.NhsNumber) && + string.IsNullOrWhiteSpace(searchCriteria.Surname), - Message = "List contains null or whitespace values" + Message = "Either an NHS number or a surname is required" }; private static void Validate( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index 2d12d3d..0d8b930 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; @@ -20,77 +21,82 @@ internal partial class PdsService : IPdsService private readonly IHttpBroker httpBroker; private readonly IIdentifierBroker identifierBroker; private readonly IApiPlatformTokenBroker tokenBroker; + private readonly ILoggingBroker loggingBroker; public PdsService( ApiPlatformConfigurations configurations, IHttpBroker httpBroker, IIdentifierBroker identifierBroker, - IApiPlatformTokenBroker tokenBroker) + IApiPlatformTokenBroker tokenBroker, + ILoggingBroker loggingBroker) { + this.loggingBroker = loggingBroker; this.configurations = configurations; this.httpBroker = httpBroker; this.identifierBroker = identifierBroker; this.tokenBroker = tokenBroker; } - public ValueTask SearchPatientsAsync( - string accessToken, - SearchCriteria searchCriteria, - CancellationToken cancellationToken = default) => - TryCatch(async () => - { - string baseUrl = this.configurations.PersonalDemographicsService.BaseUrl.TrimEnd('/'); - string url; + public ValueTask SearchPatientsAsync( + string accessToken, + SearchCriteria searchCriteria, + CancellationToken cancellationToken = default) => + TryCatch(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateOnSearchPatients(accessToken, searchCriteria); + string baseUrl = this.configurations.PersonalDemographicsService.BaseUrl.TrimEnd('/'); + string url; - if (!string.IsNullOrWhiteSpace(searchCriteria.NhsNumber)) - { - url = $"{baseUrl}/Patient/{searchCriteria.NhsNumber}"; - } - else - { - url = $"{baseUrl}/Patient?family={Uri.EscapeDataString(searchCriteria.Surname)}"; + if (!string.IsNullOrWhiteSpace(searchCriteria.NhsNumber)) + { + url = $"{baseUrl}/Patient/{Uri.EscapeDataString(searchCriteria.NhsNumber)}"; + } + else + { + url = $"{baseUrl}/Patient?family={Uri.EscapeDataString(searchCriteria.Surname)}"; - if (!string.IsNullOrWhiteSpace(searchCriteria.FirstName)) - { - url += $"&given={Uri.EscapeDataString(searchCriteria.FirstName)}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.FirstName)) + { + url += $"&given={Uri.EscapeDataString(searchCriteria.FirstName)}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.Gender)) - { - url += $"&gender={Uri.EscapeDataString(searchCriteria.Gender)}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.Gender)) + { + url += $"&gender={Uri.EscapeDataString(searchCriteria.Gender)}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.DateOfBirth)) - { - url += $"&birthdate=eq{searchCriteria.DateOfBirth:yyyy-MM-dd}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.DateOfBirth)) + { + url += $"&birthdate=eq{Uri.EscapeDataString(searchCriteria.DateOfBirth)}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.Postcode)) - { - url += $"&address-postalcode={Uri.EscapeDataString(searchCriteria.Postcode)}"; - } - } + if (!string.IsNullOrWhiteSpace(searchCriteria.Postcode)) + { + url += $"&address-postalcode={Uri.EscapeDataString(searchCriteria.Postcode)}"; + } + } - string? activeRoleId = await this.tokenBroker.GetActiveRoleAsync(cancellationToken); + string activeRoleId = await this.tokenBroker.GetActiveRoleAsync(cancellationToken); - var response = await this.httpBroker.GetAsync( - url, - request => - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Add("X-Request-ID", this.identifierBroker.GetNewGuid().ToString()); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); + var response = await this.httpBroker.GetAsync( + url, + request => + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Request-ID", this.identifierBroker.GetNewGuid().ToString()); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - if (!string.IsNullOrWhiteSpace(activeRoleId)) - { - request.Headers.Add("NHSD-Session-URID", activeRoleId); - } - }, - cancellationToken); + if (!string.IsNullOrWhiteSpace(activeRoleId)) + { + request.Headers.Add("NHSD-Session-URID", activeRoleId); + } + }, + cancellationToken); - response.EnsureSuccessStatusCode(); + response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(cancellationToken); - }); - } + return await response.Content.ReadAsStringAsync(cancellationToken); + }, cancellationToken); + } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs index 82f19a5..38b2f73 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs @@ -1,6 +1,7 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- + using System; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; @@ -20,45 +21,69 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { return await returningStringFunction(); } + catch (OperationCanceledException) + { + throw; + } + catch (NullSearchCriteriaPdsOrchestrationException nullSearchCriteriaPdsOrchestrationException) + { + throw await CreateAndLogValidationExceptionAsync(nullSearchCriteriaPdsOrchestrationException); + } catch (InvalidArgumentPdsOrchestrationException invalidArgumentPdsOrchestrationException) { - throw await CreateValidationExceptionAsync(invalidArgumentPdsOrchestrationException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentPdsOrchestrationException); } catch (UnauthorizedPdsOrchestrationException unauthorizedPdsOrchestrationException) { - throw await CreateValidationExceptionAsync(unauthorizedPdsOrchestrationException); + throw await CreateAndLogValidationExceptionAsync(unauthorizedPdsOrchestrationException); + } + catch (PdsOrchestrationValidationException) + { + throw; + } + catch (PdsOrchestrationDependencyValidationException) + { + throw; + } + catch (PdsOrchestrationDependencyException) + { + throw; + } + catch (PdsOrchestrationServiceException) + { + throw; } catch (CareIdentityServiceValidationException careIdentityValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityValidationException); } catch (CareIdentityServiceDependencyValidationException careIdentityDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityDependencyValidationException); } catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); } catch (CareIdentityServiceServiceException careIdentityServiceServiceException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); } - catch (PdsServiceValidationException pdsIdentityValidationException) + catch (PdsServiceValidationException pdsServiceValidationException) { - throw await CreateDependencyValidationExceptionAsync(pdsIdentityValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(pdsServiceValidationException); } - catch (PdsServiceDependencyValidationException pdsIdentityDependencyValidationException) + catch (PdsServiceDependencyValidationException pdsServiceDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(pdsIdentityDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(pdsServiceDependencyValidationException); } - catch (PdsServiceDependencyException pdsIdentityServiceDependencyException) + catch (PdsServiceDependencyException pdsServiceDependencyException) { - throw await CreateDependencyExceptionAsync(pdsIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(pdsServiceDependencyException); } - catch (PdsServiceException pdsIdentityServiceServiceException) + catch (PdsServiceException pdsServiceException) { - throw await CreateDependencyExceptionAsync(pdsIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(pdsServiceException); } catch (Exception exception) { @@ -68,47 +93,58 @@ private async ValueTask TryCatch(ReturningStringFunction returningString innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPdsOrchestrationException); + throw await CreateAndLogServiceExceptionAsync(failedPdsOrchestrationException); } } - private async ValueTask CreateValidationExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogValidationExceptionAsync( + Xeption exception) { var pdsOrchestrationValidationException = new PdsOrchestrationValidationException( message: "PDS orchestration validation error occurred, fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationValidationException); + return pdsOrchestrationValidationException; } - private async ValueTask CreateDependencyValidationExceptionAsync( - Xeption exception) + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) { var pdsOrchestrationDependencyValidationException = new PdsOrchestrationDependencyValidationException( message: "PDS orchestration dependency validation error occurred, fix the errors and try again.", innerException: exception.InnerException as Xeption); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyValidationException); + return pdsOrchestrationDependencyValidationException; } - private async ValueTask CreateDependencyExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogDependencyExceptionAsync( + Xeption exception) { var pdsOrchestrationDependencyException = new PdsOrchestrationDependencyException( - message: "PDS orchestration dependency error occurred, fix the errors and try again.", + message: "PDS orchestration dependency error occurred, please contact support.", innerException: exception.InnerException as Xeption); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyException); + return pdsOrchestrationDependencyException; } - private async ValueTask CreateServiceExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogServiceExceptionAsync( + Xeption exception) { var pdsOrchestrationServiceException = new PdsOrchestrationServiceException( message: "PDS orchestration service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationServiceException); + return pdsOrchestrationServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs index dbdd8f0..a30b9f5 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs @@ -1,50 +1,52 @@ // --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- + using System; -using System.Collections.Generic; -using System.Linq; -using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using Xeptions; namespace NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds { internal sealed partial class PdsOrchestrationService { - public void ValidateOnSearchPatientsAsync( - string family, - IEnumerable given, - string gender, - DateOnly? birthdate) + public void ValidateOnSearchPatients(SearchCriteria searchCriteria) { + ValidateSearchCriteriaIsNotNull(searchCriteria); + Validate( - createException: () => new InvalidArgumentCareIdentityServiceException( + createException: () => new InvalidArgumentPdsOrchestrationException( message: "Invalid argument(s), please correct the errors and try again."), - (Rule: IsInvalid(family), Parameter: nameof(family)), - (Rule: IsInvalid(given), Parameter: nameof(given)), - (Rule: IsInvalid(gender), Parameter: nameof(gender)), - (Rule: IsInvalid(birthdate), Parameter: nameof(birthdate))); + (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria))); } - private static dynamic IsInvalid(string? text) => new + public void ValidateAccessToken(string accessToken) { - Condition = string.IsNullOrWhiteSpace(text), - Message = "Text is required" - }; + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new UnauthorizedPdsOrchestrationException( + message: "Unauthorized - Unable to retrieve access token."); + } + } - private static dynamic IsInvalid(DateOnly? dateOnly) => new + private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteria) { - Condition = dateOnly == null, - Message = "Date is required" - }; + if (searchCriteria is null) + { + throw new NullSearchCriteriaPdsOrchestrationException( + message: "Search criteria is null."); + } + } - private static dynamic IsInvalid(IEnumerable textList) => new + private static dynamic IsInvalidSearchCriteria(SearchCriteria searchCriteria) => new { - Condition = textList != null && - textList.Any(text => string.IsNullOrWhiteSpace(text)), + Condition = + string.IsNullOrWhiteSpace(searchCriteria.NhsNumber) && + string.IsNullOrWhiteSpace(searchCriteria.Surname), - Message = "List contains null or whitespace values" + Message = "Either an NHS number or a surname is required" }; private static void Validate( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs index 4f008ad..f17f0e9 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs @@ -1,14 +1,10 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- -using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; -using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Patients; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; -using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; @@ -18,29 +14,30 @@ internal sealed partial class PdsOrchestrationService : IPdsOrchestrationService { private readonly ICareIdentityService careIdentityService; private readonly IPdsService pdsService; - private readonly IApiPlatformTokenBroker tokenBroker; + private readonly ILoggingBroker loggingBroker; - public PdsOrchestrationService(ICareIdentityService careIdentityService, IPdsService pdsService, IApiPlatformTokenBroker tokenBroker) + public PdsOrchestrationService( + ICareIdentityService careIdentityService, + IPdsService pdsService, + ILoggingBroker loggingBroker) { this.careIdentityService = careIdentityService; this.pdsService = pdsService; - this.tokenBroker = tokenBroker; + this.loggingBroker = loggingBroker; } public ValueTask SearchPatientsAsync( - SearchCriteria searchCriteria, + SearchCriteria searchCriteria, CancellationToken cancellationToken = default) => - TryCatch(async () => - { - string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(accessToken)) - { - throw new UnauthorizedPdsOrchestrationException("Unauthorized - Unable to retrieve access token."); - } + TryCatch(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateOnSearchPatients(searchCriteria); + string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); + ValidateAccessToken(accessToken); - return await this.pdsService - .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); - }); + return await this.pdsService + .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); + }); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs index d2f384c..5839e0d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; using Xeptions; @@ -20,15 +21,55 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { return await returningTaskFunction(); } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceProcessingException invalidArgumentCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + invalidArgumentCareIdentityServiceProcessingException); } catch (UnauthorisedCareIdentityServiceProcessingException unauthorisedCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + unauthorisedCareIdentityServiceProcessingException); + } + catch (CareIdentityServiceProcessingValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyException) + { + throw; + } + catch (CareIdentityServiceProcessingServiceException) + { + throw; + } + catch (CareIdentityServiceValidationException careIdentityServiceValidationException) + { + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityServiceValidationException); + } + catch (CareIdentityServiceDependencyValidationException + careIdentityServiceDependencyValidationException) + { + throw await CreateAndLogDependencyValidationExceptionAsync( + careIdentityServiceDependencyValidationException); + } + catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) + { + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); + } + catch (CareIdentityServiceServiceException careIdentityServiceServiceException) + { + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); } catch (Exception exception) { @@ -38,7 +79,7 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceProcessingException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceProcessingException); } } @@ -48,6 +89,56 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { await returningNothingFunction(); } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidArgumentCareIdentityServiceProcessingException + invalidArgumentCareIdentityServiceProcessingException) + { + throw await CreateAndLogValidationExceptionAsync( + invalidArgumentCareIdentityServiceProcessingException); + } + catch (UnauthorisedCareIdentityServiceProcessingException + unauthorisedCareIdentityServiceProcessingException) + { + throw await CreateAndLogValidationExceptionAsync( + unauthorisedCareIdentityServiceProcessingException); + } + catch (CareIdentityServiceProcessingValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyException) + { + throw; + } + catch (CareIdentityServiceProcessingServiceException) + { + throw; + } + catch (CareIdentityServiceValidationException careIdentityServiceValidationException) + { + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityServiceValidationException); + } + catch (CareIdentityServiceDependencyValidationException + careIdentityServiceDependencyValidationException) + { + throw await CreateAndLogDependencyValidationExceptionAsync( + careIdentityServiceDependencyValidationException); + } + catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) + { + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); + } + catch (CareIdentityServiceServiceException careIdentityServiceServiceException) + { + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); + } catch (Exception exception) { var failedCareIdentityServiceProcessingException = @@ -56,12 +147,12 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceProcessingException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceProcessingException); } } - private async ValueTask CreateValidationExceptionAsync( - Xeption exception) + private async ValueTask + CreateAndLogValidationExceptionAsync(Xeption exception) { var careIdentityServiceProcessingValidationException = new CareIdentityServiceProcessingValidationException( message: "Care identity service processing validation error occurred, " + @@ -69,16 +160,48 @@ private async ValueTask Create innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingValidationException); + return careIdentityServiceProcessingValidationException; } - private async ValueTask CreateServiceExceptionAsync( + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) + { + var careIdentityServiceProcessingDependencyValidationException = + new CareIdentityServiceProcessingDependencyValidationException( + message: "Care identity service processing dependency validation error occurred, " + + "please fix the errors and try again.", + + innerException: exception.InnerException as Xeption); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingDependencyValidationException); + + return careIdentityServiceProcessingDependencyValidationException; + } + + private async ValueTask + CreateAndLogDependencyExceptionAsync(Xeption exception) + { + var careIdentityServiceProcessingDependencyException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: exception.InnerException as Xeption); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingDependencyException); + + return careIdentityServiceProcessingDependencyException; + } + + private async ValueTask CreateAndLogServiceExceptionAsync( Xeption exception) { var careIdentityServiceProcessingServiceException = new CareIdentityServiceProcessingServiceException( message: "Care identity service processing error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingServiceException); + return careIdentityServiceProcessingServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs index 7f939a0..938e639 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; @@ -12,26 +13,36 @@ namespace NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices internal partial class CareIdentityServiceProcessingService : ICareIdentityServiceProcessingService { private readonly ICareIdentityService careIdentityService; + private readonly ILoggingBroker loggingBroker; - public CareIdentityServiceProcessingService(ICareIdentityService careIdentityService) => + public CareIdentityServiceProcessingService( + ICareIdentityService careIdentityService, + ILoggingBroker loggingBroker) + { this.careIdentityService = careIdentityService; + this.loggingBroker = loggingBroker; + } public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + return await this.careIdentityService.BuildLoginUrlAsync(cancellationToken); }); - public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); await this.careIdentityService.LogoutAsync(cancellationToken); }); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + return await this.careIdentityService.GetAccessTokenAsync(cancellationToken); }); @@ -41,6 +52,7 @@ public ValueTask GetUserInfoAsync( CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnGetUserInfo(code, state); await this.careIdentityService.CallbackAsync(code, state, cancellationToken); string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); @@ -51,6 +63,5 @@ public ValueTask GetUserInfoAsync( return userInfo; }); - } }