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