diff --git a/.github/workflows/dotnet-checks.yml b/.github/workflows/dotnet-checks.yml
index dcc7bdbf..108bfe5e 100644
--- a/.github/workflows/dotnet-checks.yml
+++ b/.github/workflows/dotnet-checks.yml
@@ -80,6 +80,7 @@ jobs:
run: |
dotnet test ./build.sln `
--no-build `
+ --filter "Category!=Integration" `
--collect "XPlat Code Coverage" `
--logger "trx;LogFileName=TestResults.trx" `
/p:AutomatedRun=true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9f91492f..04732bf5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -21,7 +21,7 @@
-
+
@@ -104,8 +104,11 @@
+
+
+
@@ -130,13 +133,20 @@
+
+
+
+
+
+
+
diff --git a/docs/testing/Integration-Tests.md b/docs/testing/Integration-Tests.md
new file mode 100644
index 00000000..51e8c8cd
--- /dev/null
+++ b/docs/testing/Integration-Tests.md
@@ -0,0 +1,217 @@
+# DfE Sign-In Internal API Integration Testing Guide
+
+This guide explains the purpose, architecture, and implementation details of the integration testing framework in the `Dfe.SignIn.InternalApi.IntegrationTests` project. Use this document as a reference for writing new integration tests and troubleshooting test environments.
+
+---
+
+## 1. Purpose of Integration Tests
+
+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.
+
+---
+
+## 2. How the Testing Flow Works
+
+The framework spins up a lightweight, isolated SQL Server instance inside a Docker container, runs EF Core database schema creation, hosts the API in-memory, and executes tests against it.
+
+```mermaid
+sequenceDiagram
+ participant TestRunner as xUnit Runner
+ participant Factory as WebApplicationFactory
+ participant Container as Docker (SQL Server)
+ participant API as In-Memory API Host
+ participant Db as Test Database
+
+ Note over TestRunner,Db: Class Initialisation (via collection fixture)
+ TestRunner->>Factory: Instantiate Factory (Starts container once)
+ Factory->>Container: Boot SQL Server container (Testcontainers)
+ Container-->>Factory: Connection string returned
+ Factory->>Factory: Map JSON settings to Env variables
+ Factory->>Db: EnsureCreated() (directories & organisations schemas)
+ Factory->>Factory: Initialise Respawner
+
+ Note over TestRunner,Db: Test Initialization (Before Each Test)
+ TestRunner->>Factory: ResetDatabasesAsync()
+ Factory->>Db: Respawn wipes tables (FKs disabled/re-enabled)
+ TestRunner->>Factory: Create HttpClient
+
+ Note over TestRunner,Db: Test Execution (Arrange, Act, Assert)
+ TestRunner->>Db: Seed entities (using scoped DbContext)
+ TestRunner->>API: HTTP POST request (using TestAuthHandler bypass)
+ API->>Db: Query/Update tables
+ Db-->>API: Data returned
+ API-->>TestRunner: HTTP Response (JSON)
+ TestRunner->>TestRunner: Assert status code and response payload
+```
+
+---
+
+## 3. Key Libraries Used
+
+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.
+
+### 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.
+
+### 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.
+
+---
+
+## 4. How to Write a New Test
+
+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
+using System.Net;
+using System.Net.Http.Json;
+using Dfe.SignIn.Core.Contracts.Organisations; // Replace with your request/response contracts
+using Dfe.SignIn.Core.Entities.Organisations; // Replace with your database entity models
+using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Contracts;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints;
+
+[Collection("IntegrationTestsCollection")]
+[Trait("Category", "Integration")]
+public class GetOrganisationByIdTests : IAsyncLifetime
+{
+ private readonly InternalApiWebApplicationFactory _factory;
+ private readonly HttpClient _client;
+
+ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _client = _factory.CreateClient();
+ }
+
+ public async Task InitializeAsync()
+ {
+ // Clear database state between tests
+ await _factory.ResetDatabasesAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists()
+ {
+ // 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
+ {
+ Id = orgId,
+ Name = expectedName,
+ Category = "001", // Required field for DB mapping
+ Status = 1, // Required field for DB mapping
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ });
+ await dbContext.SaveChangesAsync();
+
+ var request = new GetOrganisationByIdRequest { OrganisationId = orgId };
+
+ // 2. Act: Send request to the endpoint
+ var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request);
+
+ // 3. Assert: Verify response
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ var errorBody = await response.Content.ReadAsStringAsync();
+ Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorBody}");
+ }
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.NotNull(body.Data);
+ Assert.Equal(orgId, body.Data.Organisation.Id);
+ Assert.Equal(expectedName, body.Data.Organisation.Name);
+ }
+}
+```
+
+### 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
+public static class SeedExtensions
+{
+ public static async Task SeedOrganisationAsync(this InternalApiWebApplicationFactory factory, string name)
+ {
+ var id = Guid.NewGuid();
+ await using var scope = factory.Services.CreateAsyncScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ context.Organisations.Add(new OrganisationEntity
+ {
+ Id = id,
+ Name = name,
+ Category = "001",
+ Status = 1,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ });
+
+ await context.SaveChangesAsync();
+ return id;
+ }
+}
+```
+
+---
+
+## 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.
+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`.
+
+---
+
+## 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
+ ```
+
+### "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.
+
+### 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.
diff --git a/dsi-platform.sln b/dsi-platform.sln
index 87002b26..50fd02b5 100644
--- a/dsi-platform.sln
+++ b/dsi-platform.sln
@@ -119,6 +119,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.SignIn.InternalApi.IntegrationTests", "tests\Dfe.SignIn.InternalApi.IntegrationTests\Dfe.SignIn.InternalApi.IntegrationTests.csproj", "{E92E522D-3383-4356-AD07-D1C491916E26}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -729,6 +731,18 @@ Global
{5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x64.Build.0 = Release|Any CPU
{5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x86.ActiveCfg = Release|Any CPU
{5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x86.Build.0 = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x64.Build.0 = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x86.Build.0 = Debug|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x64.ActiveCfg = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x64.Build.0 = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x86.ActiveCfg = Release|Any CPU
+ {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -784,6 +798,7 @@ Global
{282680D1-4103-4AC6-9238-0038A937DAA2} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE}
{36F11936-D413-4D86-B5E7-0BF41EB2885A} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE}
{5100CDC3-5C26-B655-51E7-C5738A1DCD44} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE}
+ {E92E522D-3383-4356-AD07-D1C491916E26} = {66605B0E-3683-4A9D-B8C6-73C7146BC2D6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {86FC62DB-8432-46A5-BBDD-C58FDCB6403E}
diff --git a/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs b/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs
index 3b29f1d9..86bb3224 100644
--- a/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs
+++ b/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs
@@ -27,7 +27,14 @@ private static IReadOnlyDictionary ExceptionTypesByFullName {
}
exceptionTypesByFullName = AppDomain.CurrentDomain.GetAssemblies()
- .SelectMany(assembly => assembly.GetTypes())
+ .SelectMany(assembly => {
+ try {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e) {
+ return e.Types.Where(t => t != null)!;
+ }
+ })
.Where(type => typeof(Exception).IsAssignableFrom(type))
.GroupBy(type => type.FullName)
.ToDictionary(
diff --git a/src/Dfe.SignIn.InternalApi/Program.cs b/src/Dfe.SignIn.InternalApi/Program.cs
index 02489b02..41660131 100644
--- a/src/Dfe.SignIn.InternalApi/Program.cs
+++ b/src/Dfe.SignIn.InternalApi/Program.cs
@@ -125,3 +125,9 @@
app.UseUserEndpoints();
await app.RunAsync();
+
+// Expose the Program class to the integration tests project
+///
+/// The entry point class for the application.
+///
+public partial class Program { }
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj
new file mode 100644
index 00000000..a08dfe62
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj
@@ -0,0 +1,55 @@
+
+
+
+ {E92E522D-3383-4356-AD07-D1C491916E26}
+ net10.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs
new file mode 100644
index 00000000..00851147
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs
@@ -0,0 +1,87 @@
+using System.Net;
+using System.Net.Http.Json;
+using Dfe.SignIn.Core.Contracts.Organisations;
+using Dfe.SignIn.Core.Entities.Organisations;
+using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Contracts;
+using Microsoft.Extensions.DependencyInjection;
+using Assert = Xunit.Assert;
+
+namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints;
+
+[Collection("IntegrationTestsCollection")]
+[Trait("Category", "Integration")]
+public class GetOrganisationByIdTests : IAsyncLifetime
+{
+ private readonly InternalApiWebApplicationFactory webAppfactory;
+ private readonly HttpClient httpClient;
+
+ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory)
+ {
+ this.webAppfactory = factory;
+ this.httpClient = this.webAppfactory.CreateClient();
+ }
+
+ public async Task InitializeAsync()
+ {
+ // Clear database state between tests
+ await this.webAppfactory.ResetDatabasesAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists()
+ {
+ // Arrange: Seed an organisation
+ var orgId = Guid.NewGuid();
+ var expectedName = "Test Academy Trust";
+
+ await using var scope = this.webAppfactory.Services.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.Organisations.Add(new OrganisationEntity {
+ Id = orgId,
+ Name = expectedName,
+ Category = "001",
+ Status = 1,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ });
+ await dbContext.SaveChangesAsync();
+
+ var request = new GetOrganisationByIdRequest {
+ OrganisationId = orgId
+ };
+
+ // Act: POST to the endpoint
+ var response = await this.httpClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request);
+
+ // Assert
+ if (response.StatusCode != HttpStatusCode.OK) {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}");
+ }
+
+ var body = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(body);
+ Assert.NotNull(body.Data);
+ Assert.Equal(orgId, body.Data.Organisation.Id);
+ Assert.Equal(expectedName, body.Data.Organisation.Name);
+ }
+
+ [Fact]
+ public async Task GetOrganisationById_Returns404_WhenDoesNotExist()
+ {
+ // Arrange
+ var missingOrgId = Guid.NewGuid();
+ var request = new GetOrganisationByIdRequest {
+ OrganisationId = missingOrgId
+ };
+
+ // Act
+ var response = await this.httpClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs
new file mode 100644
index 00000000..690781a6
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs
@@ -0,0 +1,9 @@
+namespace Dfe.SignIn.InternalApi.IntegrationTests;
+
+[CollectionDefinition("IntegrationTestsCollection")]
+public class IntegrationTestCollection : ICollectionFixture
+{
+ // This class has no code, and is never created. Its purpose is simply
+ // to be the place to apply [CollectionDefinition] and all the
+ // ICollectionFixture<> interfaces.
+}
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs
new file mode 100644
index 00000000..59c0db0c
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs
@@ -0,0 +1,42 @@
+using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.TestHelpers.Integration;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Dfe.SignIn.InternalApi.IntegrationTests;
+
+public class InternalApiWebApplicationFactory : IntegrationTestFactory, IAsyncLifetime
+{
+ protected override IReadOnlyList DatabaseCatalogs
+ => [
+ new("dsi-directories-test", "Directories", typeof(DbDirectoriesContext)),
+ new("dsi-organisations-test", "Organisations", typeof(DbOrganisationsContext))
+ ];
+
+ protected override string AppSettingsFileName => "appsettings.Test.json";
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ builder.ConfigureTestServices(services => {
+ services.AddAuthentication(options => {
+ options.DefaultAuthenticateScheme = "TestScheme";
+ options.DefaultChallengeScheme = "TestScheme";
+ })
+ .AddScheme("TestScheme", _ => { });
+ });
+ }
+
+ public async Task InitializeAsync()
+ {
+ await this.InitialiseDatabasesAsync();
+ }
+
+ async Task IAsyncLifetime.DisposeAsync()
+ {
+ await this.DisposeAsync();
+ }
+}
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json
new file mode 100644
index 00000000..b8c212cc
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json
@@ -0,0 +1,20 @@
+{
+ "AzureAd": {
+ "Audience": "dummy-audience",
+ "Instance": "https://login.microsoftonline.com",
+ "TenantId": "00000000-0000-0000-0000-000000000000"
+ },
+ "InternalApiClient": {
+ "Tenant": "00000000-0000-0000-0000-000000000000",
+ "ClientId": "00000000-0000-0000-0000-000000000000",
+ "ClientSecret": "dummy-secret",
+ "HostUrl": "https://localhost",
+ "BaseUrl": "https://localhost"
+ },
+ "PublicApiSecretEncryption": {
+ "Key": "some-encryption-key-for-test-32b"
+ },
+ "ServiceBus": {
+ "AuditTopic": "dummy-audit-topic"
+ }
+}
diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj
index 27042e1f..a1eb3856 100644
--- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj
+++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj
@@ -17,7 +17,13 @@
+
+
+
+
+
+
diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs
new file mode 100644
index 00000000..ab194a0e
--- /dev/null
+++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs
@@ -0,0 +1,90 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+
+namespace Dfe.SignIn.TestHelpers.Integration;
+///
+/// A reusable base for integration tests.
+/// Manages SQL container lifecycle, configuration bridging, and database resets.
+///
+///
+/// The entry point class of the API under test (e.g., Program).
+///
+public abstract class IntegrationTestFactory : WebApplicationFactory
+ where TProgram : class
+{
+ private readonly SqlDatabaseManager _dbManager = new();
+
+ ///
+ /// Defines the database catalogs that this API requires.
+ /// Subclasses must override this to declare their catalogs.
+ ///
+ protected abstract IReadOnlyList DatabaseCatalogs { get; }
+
+ protected abstract string AppSettingsFileName { get; }
+
+ protected IntegrationTestFactory()
+ {
+ Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Test");
+
+ LoadStaticConfigurations(this.AppSettingsFileName);
+
+ // The container must be started synchronously here because environment variables
+ // need to be set before the WebApplicationFactory builds its host in ConfigureWebHost.
+ this._dbManager.StartAsync(this.DatabaseCatalogs).GetAwaiter().GetResult();
+ this._dbManager.SetConnectionEnvironmentVariables();
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Local");
+ }
+
+ ///
+ /// Called after the host has been built and Services are available.
+ /// Creates database schemas and initialises Respawners.
+ /// This is called automatically by xUnit via .
+ ///
+ public async Task InitialiseDatabasesAsync()
+ {
+ await this._dbManager.InitialiseSchemasAsync(this.DatabaseCatalogs, this.Services);
+ }
+
+ ///
+ /// Resets all databases to their empty schema state via Respawn.
+ /// Call from [TestInitialize] or equivalent.
+ ///
+ public async Task ResetDatabasesAsync()
+ {
+ await this._dbManager.ResetAllAsync();
+ }
+
+ ///
+ /// Loads appsettings.IntegrationTests.json from the test project's output directory
+ /// and writes each key-value pair as an environment variable using the __ separator.
+ ///
+ private static void LoadStaticConfigurations(string appSettingsFileName)
+ {
+ if (string.IsNullOrEmpty(appSettingsFileName)) {
+ return;
+ }
+
+ var config = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile(appSettingsFileName, optional: false)
+ .Build();
+
+ foreach (var pair in config.AsEnumerable()) {
+ if (pair.Value is not null) {
+ var envKey = pair.Key.Replace(":", "__");
+ Environment.SetEnvironmentVariable(envKey, pair.Value);
+ }
+ }
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await this._dbManager.DisposeAsync();
+ await base.DisposeAsync();
+ }
+}
diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs b/tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs
new file mode 100644
index 00000000..dc693e2e
--- /dev/null
+++ b/tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs
@@ -0,0 +1,108 @@
+
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Respawn;
+using Testcontainers.MsSql;
+
+namespace Dfe.SignIn.TestHelpers.Integration;
+
+///
+/// Describes a database catalog to provision inside the SQL container.
+///
+/// The SQL catalog name (e.g., "dsi-directories-test").
+///
+/// The configuration key prefix used to set EntityFramework__[ConfigKey]__Host/Name/Username/Password
+/// environment variables (e.g., "Directories").
+///
+/// The EF Core type to run EnsureCreated on.
+public sealed record DatabaseCatalog(string CatalogName, string ConfigKey, Type DbContextType);
+
+///
+/// Manages a single SQL Server Testcontainer, creates database catalogs,
+/// initialises schemas via EF Core, and provides Respawn-based state resets.
+///
+public sealed class SqlDatabaseManager : IAsyncDisposable
+{
+ private readonly MsSqlContainer _container = new MsSqlBuilder()
+ .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
+ .Build();
+
+ private readonly Dictionary _connectionStrings = [];
+ private readonly Dictionary _respawners = [];
+
+ ///
+ /// Starts the container and builds catalog connection strings.
+ ///
+ public async Task StartAsync(IReadOnlyList catalogs)
+ {
+ await this._container.StartAsync();
+
+ var containerConnectionString = this._container.GetConnectionString();
+
+ foreach (var catalog in catalogs) {
+ var csBuilder = new SqlConnectionStringBuilder(containerConnectionString) {
+ InitialCatalog = catalog.CatalogName
+ };
+ this._connectionStrings[catalog.ConfigKey] = csBuilder.ConnectionString;
+ }
+ }
+
+ ///
+ /// Sets environment variables for each catalog so that the host's
+ /// EntityFramework:{ConfigKey}:Host/Name/Username/Password configuration
+ /// resolves correctly from the container's dynamic connection string.
+ ///
+ public void SetConnectionEnvironmentVariables()
+ {
+ foreach (var (configKey, cs) in this._connectionStrings) {
+ var connectionStringBuilder = new SqlConnectionStringBuilder(cs);
+ Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Host", connectionStringBuilder.DataSource);
+ Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Name", connectionStringBuilder.InitialCatalog);
+ Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Username", connectionStringBuilder.UserID);
+ Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Password", connectionStringBuilder.Password);
+ }
+ }
+
+ ///
+ /// Initialises database schemas via EF Core EnsureCreated and builds Respawners.
+ ///
+ public async Task InitialiseSchemasAsync(IReadOnlyList catalogs, IServiceProvider serviceProvider)
+ {
+ foreach (var catalog in catalogs) {
+ var catalogConnectionString = this._connectionStrings[catalog.ConfigKey];
+
+ using var scope = serviceProvider.CreateScope();
+ var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(catalog.DbContextType);
+ await dbContext.Database.EnsureCreatedAsync();
+
+ using var connection = new SqlConnection(catalogConnectionString);
+ await connection.OpenAsync();
+
+ var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions {
+ DbAdapter = DbAdapter.SqlServer
+ });
+
+ this._respawners[catalog.ConfigKey] = respawner;
+ }
+ }
+
+ ///
+ /// Resets all databases back to empty schemas using Respawn.
+ ///
+ public async Task ResetAllAsync()
+ {
+ foreach (var (configKey, respawner) in this._respawners) {
+ var catalogConnectionString = this._connectionStrings[configKey];
+ await using var connection = new SqlConnection(catalogConnectionString);
+ await connection.OpenAsync();
+ await respawner.ResetAsync(connection);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await this._container.DisposeAsync();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs
new file mode 100644
index 00000000..94a36fcb
--- /dev/null
+++ b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs
@@ -0,0 +1,34 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Dfe.SignIn.TestHelpers.Integration;
+
+public class TestAuthHandler : AuthenticationHandler
+{
+ public TestAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : base(options, logger, encoder)
+ {
+ }
+
+ private const string TestUserId = "00000000-0000-0000-0000-000000000001";
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.Name, "TestUser"),
+ new Claim(ClaimTypes.NameIdentifier, TestUserId)
+ };
+ var identity = new ClaimsIdentity(claims, "TestScheme");
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, "TestScheme");
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+}