diff --git a/Directory.Packages.props b/Directory.Packages.props index 04732bf5..cb041197 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/docs/testing/Integration-Tests.md b/docs/testing/Integration-Tests.md index 51e8c8cd..41053a91 100644 --- a/docs/testing/Integration-Tests.md +++ b/docs/testing/Integration-Tests.md @@ -9,10 +9,11 @@ This guide explains the purpose, architecture, and implementation details of the While unit tests validate business logic in isolation by mocking all external dependencies, **integration tests** verify that the entire application stack works together correctly. In the DfE Sign-In platform, integration tests ensure: -* **Database Schema Compatibility**: Entity Framework Core mappings align perfectly with real SQL Server database schemas. -* **API Route and Contract Validation**: HTTP endpoints are correctly mapped, accept the expected request payloads, and return correct response payloads. -* **Security & Interception**: Custom middlewares, filters, logging contexts, and exception handling blocks function correctly in a real HTTP lifecycle. -* **Resilience & DI Setup**: Dependency injection registrations resolve correctly without runtime exceptions during host startup. + +- **Database Schema Compatibility**: Entity Framework Core mappings align perfectly with real SQL Server database schemas. +- **API Route and Contract Validation**: HTTP endpoints are correctly mapped, accept the expected request payloads, and return correct response payloads. +- **Security & Interception**: Custom middlewares, filters, logging contexts, and exception handling blocks function correctly in a real HTTP lifecycle. +- **Resilience & DI Setup**: Dependency injection registrations resolve correctly without runtime exceptions during host startup. --- @@ -50,6 +51,25 @@ sequenceDiagram TestRunner->>TestRunner: Assert status code and response payload ``` +### 2.1 How test authentication works + +Integration tests do not use a real identity provider. Instead, they use a test authentication scheme so the full HTTP pipeline can still enforce authorisation rules. + +Approach: + +1. The test host configures a custom scheme (`TestScheme`) as the default authenticate/challenge scheme. +2. Tests call `Authenticate()` on `HttpClient` to add `X-Test-Auth: true`. +3. A custom auth handler checks this header and builds a `ClaimsPrincipal` for the request. +4. If no user details are supplied, the handler uses default test values for user ID and username. +5. Endpoints still require authorisation (`RequireAuthorization()`), so requests without test auth headers return `401`. + +Key references to apply when writing or troubleshooting tests: + +- `InternalApiWebApplicationFactory.ConfigureWebHost` (registers the test auth scheme/handler) +- `HttpClientExtensions.Authenticate` (adds test auth headers to requests) +- `TestAuthHandler.HandleAuthenticateAsync` (creates claims and fallback defaults) +- `InteractionHandlerExtensions.Map` with `RequireAuthorization()` (enforces auth on interaction endpoints) + --- ## 3. Key Libraries Used @@ -57,16 +77,22 @@ sequenceDiagram We utilize three main libraries to keep integration tests fast, isolated, and easy to maintain: ### 1. `Microsoft.AspNetCore.Mvc.Testing` (`WebApplicationFactory`) + Hosts the API in-memory using a test-specific web server. -* **Why**: It allows sending real HTTP requests to your endpoints and testing the full middleware pipeline without running a slow, external IIS or Kestrel process. + +- **Why**: It allows sending real HTTP requests to your endpoints and testing the full middleware pipeline without running a slow, external IIS or Kestrel process. ### 2. `Testcontainers.MsSql` + Provides lightweight, disposable SQL Server instances running in Docker. -* **Why**: It avoids the need for a shared, local database server that could contain stale data. Each test run gets a 100% fresh, isolated SQL Server instance. + +- **Why**: It avoids the need for a shared, local database server that could contain stale data. Each test run gets a 100% fresh, isolated SQL Server instance. ### 3. `Respawn` + Resets the state of database tables back to a blank schema. -* **Why**: Dropping and recreating database schemas for every test takes seconds. Respawn queries the database metadata, disables foreign keys, deletes all data using `DELETE`/`TRUNCATE` statements, and re-enables constraints in **under 15 milliseconds**, ensuring total test isolation without a speed penalty. + +- **Why**: Dropping and recreating database schemas for every test takes seconds. Respawn queries the database metadata, disables foreign keys, deletes all data using `DELETE`/`TRUNCATE` statements, and re-enables constraints in **under 15 milliseconds**, ensuring total test isolation without a speed penalty. --- @@ -75,6 +101,7 @@ Resets the state of database tables back to a blank schema. Follow these steps to add integration tests for a new endpoint: ### Step 1: Add a Test Class + Create a new test file under the `Endpoints/` folder. Use the following class template: ```csharp @@ -116,7 +143,7 @@ public class GetOrganisationByIdTests : IAsyncLifetime // 1. Arrange: Seed your data using the scoped DbContext var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - + await using var scope = _factory.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Organisations.Add(new OrganisationEntity @@ -152,6 +179,7 @@ public class GetOrganisationByIdTests : IAsyncLifetime ``` ### Step 2: Implement Common Seeding Helpers (Optional) + If you find yourself seeding the same entities (like a default user or organisation) across multiple test classes, create an extension class to reuse the code: ```csharp @@ -162,7 +190,7 @@ public static class SeedExtensions var id = Guid.NewGuid(); await using var scope = factory.Services.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); - + context.Organisations.Add(new OrganisationEntity { Id = id, @@ -172,7 +200,7 @@ public static class SeedExtensions CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); - + await context.SaveChangesAsync(); return id; } @@ -184,10 +212,12 @@ public static class SeedExtensions ## 5. Configuration & Under-The-Hood Details ### Minimal API Startup Limitation + In .NET 6+ Minimal APIs, using `ConfigureWebHost` to override configuration properties happens **after** `Program.cs` starts parsing `builder.Configuration`. Since the API project checks configuration keys immediately during bootstrap (`CreateBuilder(args)`), standard `WebApplicationFactory` configuration overrides are too late and cause "Section not found" exceptions. ### Solution: JSON-to-Environment Variable Mapping -We solve this by loading static test settings from [appsettings.IntegrationTests.json](file:///c:/Work/Playground/dsi-workspace/dsi-platform/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json) (or custom test settings file defined by the factory) in the constructor and dumping them as process environment variables. + +We solve this by loading static test settings from [appsettings.IntegrationTests.json](file:///c:/Work/Playground/dsi-workspace/dsi-platform/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json) (or custom test settings file defined by the factory) in the constructor and dumping them as process environment variables. The configuration binder translates double underscores (`__`) into colons (`:`), resulting in a perfect representation of configuration sections (e.g. `AzureAd__Audience` maps to `AzureAd:Audience`) which are processed in time by `Program.cs`. --- @@ -195,23 +225,29 @@ The configuration binder translates double underscores (`__`) into colons (`:`), ## 6. Troubleshooting & Tips for Developers ### "An Application Control policy has blocked this file (0x800711C7)" + This occurs on corporate-managed laptops when Windows AppLocker blocks the execution/loading of DLLs in arbitrary folders (like `C:\Users\` or user sandbox folders). -* **Fix 1**: Move/clone your workspace folder to a whitelisted developer directory, such as `C:\git\` or `C:\source\`. -* **Fix 2**: Run tests inside **WSL 2** (Ubuntu/Linux terminal) where Windows AppLocker policies do not apply: - ```bash - dotnet test tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj - ``` + +- **Fix 1**: Move/clone your workspace folder to a whitelisted developer directory, such as `C:\git\` or `C:\source\`. +- **Fix 2**: Run tests inside **WSL 2** (Ubuntu/Linux terminal) where Windows AppLocker policies do not apply: + ```bash + dotnet test tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj + ``` ### "Docker is either not running or misconfigured" + Testcontainers requires a running Docker daemon. -* **Fix**: Ensure **Docker Desktop** is running. If Docker is active and the error persists: - * Disable **"Resource Saver Mode"** in Docker Desktop settings (known to freeze background container boot requests). - * If using WSL 2 backend, ensure integration is enabled for your current distro. + +- **Fix**: Ensure **Docker Desktop** is running. If Docker is active and the error persists: + - Disable **"Resource Saver Mode"** in Docker Desktop settings (known to freeze background container boot requests). + - If using WSL 2 backend, ensure integration is enabled for your current distro. ### Test execution is stuck at `StartAsync` / Taking too long + The first time integration tests run, Testcontainers has to pull the large Microsoft SQL Server image. Because it pulls the image silently over the Docker API, it looks like the test runner is frozen. -* **Fix**: Run a manual pull in your terminal once to download the image with progress indicators: - ```bash - docker pull mcr.microsoft.com/mssql/server:2022-latest - ``` - All subsequent runs will boot instantly since the image will be cached in your local Docker registry. + +- **Fix**: Run a manual pull in your terminal once to download the image with progress indicators: + ```bash + docker pull mcr.microsoft.com/mssql/server:2022-latest + ``` + All subsequent runs will boot instantly since the image will be cached in your local Docker registry. diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index a08dfe62..210c234c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -40,10 +40,8 @@ - - - - + + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs deleted file mode 100644 index cb357e74..00000000 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; - -[Collection("IntegrationTestsCollection")] -public abstract class IntegrationEndpointTestBase(InternalApiWebApplicationFactory webAppFactory) : IAsyncLifetime -{ - protected InternalApiWebApplicationFactory WebAppFactory { get; } = webAppFactory; - - public async Task InitializeAsync() - { - await this.WebAppFactory.ResetDatabasesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - protected HttpClient CreateAuthenticatedClient(string? userId = null, string? userName = null, params string[] roles) - { - return this.WebAppFactory.CreateAuthenticatedClient(userId, userName, roles); - } - - protected HttpClient CreateAnonymousClient() - { - return this.WebAppFactory.CreateAnonymousClient(); - } -} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs similarity index 81% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs rename to tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index e151beac..989d6f7e 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -4,14 +4,17 @@ using Dfe.SignIn.Core.Entities.Organisations; using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; +using Dfe.SignIn.TestHelpers.Integration.Extensions; using Microsoft.Extensions.DependencyInjection; using Assert = Xunit.Assert; -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Organisations; [Trait("Category", "Integration")] -public class GetOrganisationByIdTests : IntegrationEndpointTestBase +public class GetOrganisationByIdTests : InternalApiIntegrationEndpointTestBase { + private const string endpoint = "interaction/Organisations.GetOrganisationById"; + public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) : base(factory) { @@ -20,7 +23,7 @@ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { - var authenticatedClient = this.CreateAuthenticatedClient(); + var authenticatedClient = this.CreateClient().WithAuthentication(); // Arrange: Seed an organisation var orgId = Guid.NewGuid(); @@ -43,7 +46,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() }; // Act: POST to the endpoint - var response = await authenticatedClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); // Assert if (response.StatusCode != HttpStatusCode.OK) { @@ -61,7 +64,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() [Fact] public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { - var authenticatedClient = this.CreateAuthenticatedClient(); + var authenticatedClient = this.CreateClient().WithAuthentication(); // Arrange var missingOrgId = Guid.NewGuid(); @@ -70,7 +73,7 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() }; // Act - var response = await authenticatedClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -79,13 +82,13 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() [Fact] public async Task GetOrganisationById_Returns401_WhenUnauthenticated() { - var anonymousClient = this.CreateAnonymousClient(); + var anonymousClient = this.CreateClient(); var request = new GetOrganisationByIdRequest { OrganisationId = Guid.NewGuid() }; - var response = await anonymousClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await anonymousClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs new file mode 100644 index 00000000..d513b31c --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Net.Http.Json; +using Dfe.SignIn.Core.Contracts.Audit; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.Core.Entities.Directories; +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.Contracts; +using Dfe.SignIn.TestHelpers.Integration.Data; +using Dfe.SignIn.TestHelpers.Integration.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Assert = Xunit.Assert; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Users; + +[Trait("Category", "Integration")] +public class ChangeJobTitleTests : InternalApiIntegrationEndpointTestBase +{ + private const string endpoint = "interaction/Users.ChangeJobTitle"; + + public ChangeJobTitleTests(InternalApiWebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task ChangeJobTitle_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUserExists() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var expectedJobTitle = "Senior Software Developer"; + var user = EntityFaker.User + .RuleFor(x => x.JobTitle, (_, _) => "Old Title") + .Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = expectedJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(expectedJobTitle, updatedUser.JobTitle); + + var auditRequest = auditMock.CapturedRequest; + Assert.NotNull(auditRequest); + Assert.Equal(AuditEventCategoryNames.ChangeJobTitle, auditRequest.EventCategory); + Assert.Equal($"Successfully changed job title to {expectedJobTitle}", auditRequest.Message); + Assert.Equal(user.Sub, auditRequest.UserId); + } + + [Fact] + public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() + { + var authenticatedClient = this.CreateClient().WithAuthentication(); + + var request = new ChangeJobTitleRequest { + UserId = Guid.NewGuid(), + NewJobTitle = "Senior Software Developer" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() + { + var anonymousClient = this.CreateClient(); + + var request = new ChangeJobTitleRequest { + UserId = Guid.NewGuid(), + NewJobTitle = "Senior Software Developer" + }; + + var response = await anonymousClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var jobTitle = "Senior Software Developer"; + var user = EntityFaker.User + .RuleFor(x => x.JobTitle, (_, _) => jobTitle) + .Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = jobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(jobTitle, updatedUser.JobTitle); + + var auditRequest = auditMock.CapturedRequest; + Assert.Null(auditRequest); + } + + [Fact] + public async Task ChangeJobTitle_NormalisesWhitespaceBeforeSaving() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newjobTitle = "Senior Software Developer"; // Intentionally includes multiple spaces + var expectedJobTitle = "Senior Software Developer"; + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = newjobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(expectedJobTitle, updatedUser.JobTitle); + } + + [Fact] + public async Task ChangeJobTitle_LeavesOtherUsersUnchanged() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newJobTitle = "Senior Software Developer"; + var users = EntityFaker.User.Generate(3); + + await this.InsertEntitiesAsync(users); + + var userToUpdate = users[1]; + + var request = new ChangeJobTitleRequest { + UserId = userToUpdate.Sub, + NewJobTitle = newJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + + var userSubs = users.Select(u => u.Sub).ToList(); + var assertionUsers = await assertionDbContext.Users + .Where(x => userSubs.Contains(x.Sub)) + .ToListAsync(); + + var updatedUser = assertionUsers.Single(x => x.Sub == userToUpdate.Sub); + Assert.Equal(newJobTitle, updatedUser.JobTitle); + + var unchangedUsers = assertionUsers.Where(x => x.Sub != userToUpdate.Sub).ToList(); + foreach (var unchangedUser in unchangedUsers) { + Assert.NotEqual(newJobTitle, unchangedUser.JobTitle); + } + } + + [Fact] + public async Task ChangeJobTitle_Returns400_WhenNewJobTitleIsInvalid() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = "Senior Software Engineer!!!" // Invalid characters in job title + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var longJobTitle = "Vice President of Global Human Capital Management and Organizational Culture Development"; + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = longJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs new file mode 100644 index 00000000..b3320cab --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs @@ -0,0 +1,86 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Audit; +using Dfe.SignIn.TestHelpers.Integration; +using Dfe.SignIn.TestHelpers.Integration.Mocks; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Dfe.SignIn.InternalApi.IntegrationTests; + +[Collection("InternalApiIntegrationTestCollection")] +public abstract class InternalApiIntegrationEndpointTestBase(InternalApiWebApplicationFactory webAppFactory) : IAsyncLifetime +{ + private readonly List createdFactories = []; + + protected InternalApiWebApplicationFactory WebAppFactory { get; } = webAppFactory; + + public async Task InitializeAsync() + { + await this.WebAppFactory.ResetDatabasesAsync(); + } + + public Task DisposeAsync() + { + return this.DisposeCreatedFactoriesAsync(); + } + + /// + /// This method creates a new HttpClient instance with a mock implementation of the IInteractor interface. + /// The mock implementation captures the WriteToAuditRequest passed to it, allowing for verification of audit logging behavior during integration tests. + /// NOTE: This has only been added to support the interator that writes to audit, so that we can verify that the correct audit events are being written during integration tests. + /// NOTE: When we move to a simpler IAuditorService implementation, this method can be removed and the tests can be updated to use the real implementation of IAuditorService. + /// + /// + protected (HttpClient Client, CapturingWriteToAuditInteractor AuditMock) CreateClientWithAuditMock() + { + var auditMock = new CapturingWriteToAuditInteractor(); + + var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { + builder.ConfigureTestServices(services => { + services.RemoveAll>(); + services.AddSingleton>(auditMock); + }); + }); + + this.createdFactories.Add(customisedFactory); + + var client = customisedFactory.CreateClient(); + client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + return (client, auditMock); + } + + protected async Task InsertEntityAsync(TEntity entity) + where TContext : DbContext + where TEntity : class + { + await this.InsertEntitiesAsync([entity]); + } + + protected async Task InsertEntitiesAsync(IEnumerable entities) + where TContext : DbContext + where TEntity : class + { + await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.AddRange(entities); + await dbContext.SaveChangesAsync(); + } + + private async Task DisposeCreatedFactoriesAsync() + { + foreach (var createdFactory in this.createdFactories) { + await createdFactory.DisposeAsync(); + } + + this.createdFactories.Clear(); + } + + protected HttpClient CreateClient() + { + return this.WebAppFactory.CreateClient(); + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs similarity index 58% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs rename to tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs index 690781a6..ce551dab 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs @@ -1,7 +1,7 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests; -[CollectionDefinition("IntegrationTestsCollection")] -public class IntegrationTestCollection : ICollectionFixture +[CollectionDefinition("InternalApiIntegrationTestCollection")] +public class InternalApiIntegrationTestCollection : ICollectionFixture { // This class has no code, and is never created. Its purpose is simply // to be the place to apply [CollectionDefinition] and all the diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index a1eb3856..8f453188 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -6,8 +6,9 @@ enable enable false + false - + @@ -22,6 +23,8 @@ + + @@ -37,6 +40,8 @@ + + diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs new file mode 100644 index 00000000..c8eb49f0 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs @@ -0,0 +1,19 @@ +using Bogus; +using Dfe.SignIn.Core.Entities.Directories; + +namespace Dfe.SignIn.TestHelpers.Integration.Data; + +public static class EntityFaker +{ + public static Faker User => new Faker() + .RuleFor(x => x.Sub, f => f.Random.Guid()) + .RuleFor(x => x.Email, f => f.Internet.Email()) + .RuleFor(x => x.FirstName, f => f.Name.FirstName()) + .RuleFor(x => x.LastName, f => f.Name.LastName()) + .RuleFor(x => x.Password, f => f.Internet.Password()) + .RuleFor(x => x.Salt, f => f.Random.AlphaNumeric(32)) + .RuleFor(x => x.Status, _ => (short)1) + .RuleFor(x => x.CreatedAt, f => f.Date.Past(2)) + .RuleFor(x => x.UpdatedAt, (f, user) => f.Date.Between(user.CreatedAt, DateTime.UtcNow)) + .RuleFor(x => x.JobTitle, (_, _) => "Old Title"); +} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs new file mode 100644 index 00000000..873c859c --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs @@ -0,0 +1,23 @@ +namespace Dfe.SignIn.TestHelpers.Integration.Extensions; + +public static class HttpClientExtensions +{ + public static HttpClient WithAuthentication(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) + { + client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + if (!string.IsNullOrWhiteSpace(userId)) { + client.DefaultRequestHeaders.Add(TestAuthHandler.UserIdHeaderName, userId); + } + + if (!string.IsNullOrWhiteSpace(userName)) { + client.DefaultRequestHeaders.Add(TestAuthHandler.UserNameHeaderName, userName); + } + + foreach (var role in roles.Where(static r => !string.IsNullOrWhiteSpace(r))) { + client.DefaultRequestHeaders.Add(TestAuthHandler.RoleHeaderName, role); + } + + return client; + } +} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs new file mode 100644 index 00000000..59fada94 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs @@ -0,0 +1,17 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Audit; + +namespace Dfe.SignIn.TestHelpers.Integration.Mocks; + +public sealed class CapturingWriteToAuditInteractor : IInteractor +{ + public WriteToAuditRequest? CapturedRequest { get; private set; } + + public Task InvokeAsync( + InteractionContext context, + CancellationToken cancellationToken = default) + { + this.CapturedRequest = context.Request; + return Task.FromResult(new WriteToAuditResponse()); + } +}