From a786752aa3539b17e5171870e560e375b5ade373 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Wed, 15 Nov 2023 12:49:26 +0000 Subject: [PATCH] Dynamic API client for tests --- .../Security/ApiKeyAuthenticationHandler.cs | 6 +- .../appsettings.Testing.json | 5 -- .../ApiFixture.cs | 10 +++ .../ApiTestBase.cs | 11 +++- .../Security/TestAuthentication.cs | 61 +++++++++++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/Infrastructure/Security/TestAuthentication.cs diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Security/ApiKeyAuthenticationHandler.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Security/ApiKeyAuthenticationHandler.cs index a018dbbbd..d47cf5c8c 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Security/ApiKeyAuthenticationHandler.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Security/ApiKeyAuthenticationHandler.cs @@ -46,7 +46,7 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail($"No client found with specified API key.")); } - var principal = CreatePrincipal(client); + var principal = CreatePrincipal(client.ClientId); var ticket = new AuthenticationTicket(principal, Scheme.Name); LogContext.PushProperty("ClientId", client.ClientId); @@ -54,11 +54,11 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Success(ticket)); } - private static ClaimsPrincipal CreatePrincipal(ApiClient client) + public static ClaimsPrincipal CreatePrincipal(string clientId) { var identity = new ClaimsIdentity(new[] { - new Claim(ClaimTypes.Name, client.ClientId) + new Claim(ClaimTypes.Name, clientId) }); return new ClaimsPrincipal(identity); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Testing.json b/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Testing.json index e997509a4..a32dd535b 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Testing.json +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Testing.json @@ -1,10 +1,5 @@ { "Platform": "Local", - "ApiClients": { - "tests": { - "ApiKey": [ "tests" ] - } - }, "Serilog": { "MinimumLevel": { "Default": "Error", diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiFixture.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiFixture.cs index f9daf7557..36ec2a3e2 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiFixture.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiFixture.cs @@ -1,10 +1,13 @@ using System.Security.Cryptography; using JustEat.HttpClientInterception; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using TeachingRecordSystem.Api.Infrastructure.Security; +using TeachingRecordSystem.Api.Tests.Infrastructure.Security; using TeachingRecordSystem.Core.Dqt; using TeachingRecordSystem.Core.Services.AccessYourQualifications; using TeachingRecordSystem.Core.Services.Certificates; @@ -41,9 +44,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { DbHelper.ConfigureDbServices(services, context.Configuration.GetRequiredConnectionString("DefaultConnection")); + // Replace ApiKeyAuthenticationHandler with a mechanism we can control from tests + services.Configure(options => + { + options.SchemeMap[ApiKeyAuthenticationHandler.AuthenticationScheme].HandlerType = typeof(TestAuthenticationHandler); + }); + // Add controllers defined in this test assembly services.AddMvc().AddApplicationPart(typeof(ApiFixture).Assembly); + services.AddSingleton(); services.AddTestScoped(tss => tss.Clock); services.AddTestScoped(tss => tss.DataverseAdapterMock.Object); services.AddTestScoped(tss => tss.GetAnIdentityApiClientMock.Object); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiTestBase.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiTestBase.cs index b67d89ad8..92c348f63 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiTestBase.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/ApiTestBase.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using TeachingRecordSystem.Api.Infrastructure.Json; +using TeachingRecordSystem.Api.Tests.Infrastructure.Security; using TeachingRecordSystem.Core.DataStore.Postgres; using TeachingRecordSystem.Core.Dqt; using TeachingRecordSystem.Core.Services.Certificates; @@ -20,11 +21,11 @@ protected ApiTestBase(ApiFixture apiFixture) { ApiFixture = apiFixture; _testServices = TestScopedServices.Reset(); + SetCurrentApiClient("tests"); { - var key = apiFixture.Services.GetRequiredService()["ApiClients:tests:ApiKey:0"]; HttpClientWithApiKey = apiFixture.CreateClient(); - HttpClientWithApiKey.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key); + HttpClientWithApiKey.DefaultRequestHeaders.Add("X-Use-CurrentClientIdProvider", "true"); // Signal for TestAuthenticationHandler to run } } @@ -79,6 +80,12 @@ public HttpClient GetHttpClientWithIdentityAccessToken(string trn, string scope return httpClient; } + protected void SetCurrentApiClient(string clientId) + { + var currentUserProvider = ApiFixture.Services.GetRequiredService(); + currentUserProvider.CurrentApiClientId = clientId; + } + public virtual async Task WithDbContext(Func> action) { var dbContextFactory = ApiFixture.Services.GetRequiredService>(); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/Infrastructure/Security/TestAuthentication.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/Infrastructure/Security/TestAuthentication.cs new file mode 100644 index 000000000..b1c0d9f82 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/Infrastructure/Security/TestAuthentication.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using TeachingRecordSystem.Api.Infrastructure.Security; + +namespace TeachingRecordSystem.Api.Tests.Infrastructure.Security; + +public class TestAuthenticationHandler : AuthenticationHandler +{ + private readonly CurrentApiClientProvider _currentApiClientProvider; + + public TestAuthenticationHandler( + CurrentApiClientProvider currentApiClientProvider, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : + base(options, logger, encoder, clock) + { + _currentApiClientProvider = currentApiClientProvider; + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("X-Use-CurrentClientIdProvider", out var useCurrentClientIdProvider) || + useCurrentClientIdProvider != "true") + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var currentApiClientId = _currentApiClientProvider.CurrentApiClientId; + + if (currentApiClientId is not null) + { + var principal = ApiKeyAuthenticationHandler.CreatePrincipal(currentApiClientId); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + else + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + } +} + +public class TestAuthenticationOptions : AuthenticationSchemeOptions { } + +public class CurrentApiClientProvider +{ + private readonly AsyncLocal _currentApiClientId = new(); + + [DisallowNull] + public string? CurrentApiClientId + { + get => _currentApiClientId.Value; + set => _currentApiClientId.Value = value; + } +}