diff --git a/src/Dfe.SignIn.Core.Contracts/Features/Users/IUsersApiClient.cs b/src/Dfe.SignIn.Core.Contracts/Features/Users/IUsersApiClient.cs
index 903fd05c..49f7ef20 100644
--- a/src/Dfe.SignIn.Core.Contracts/Features/Users/IUsersApiClient.cs
+++ b/src/Dfe.SignIn.Core.Contracts/Features/Users/IUsersApiClient.cs
@@ -77,4 +77,13 @@ public interface IUsersApiClient
/// A task representing the asynchronous operation.
[Post(UsersApiRoutes.ConfirmChangeEmail)]
Task ConfirmChangeEmailAddress(Guid userId, [Body] ConfirmChangeEmailAddressRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Cancels the process of changing a user's email address.
+ ///
+ /// The ID of the user whose email address change is being cancelled.
+ /// A token to monitor for cancellation requests.
+ /// A task representing the asynchronous operation.
+ [Delete(UsersApiRoutes.CancelChangeEmail)]
+ Task CancelChangeEmailAddress(Guid userId, CancellationToken cancellationToken = default);
}
diff --git a/src/Dfe.SignIn.Core.Contracts/Features/Users/Shared/UserCodeType.cs b/src/Dfe.SignIn.Core.Contracts/Features/Users/Shared/UserCodeType.cs
new file mode 100644
index 00000000..64bd5c67
--- /dev/null
+++ b/src/Dfe.SignIn.Core.Contracts/Features/Users/Shared/UserCodeType.cs
@@ -0,0 +1,38 @@
+namespace Dfe.SignIn.Core.Contracts.Features.Users.Shared;
+
+///
+/// Represents the type of user code used for various user actions, such as changing email or resetting password.
+///
+public enum UserCodeType
+{
+ ///
+ /// Represents a user code type used for changing a user's email address.
+ ///
+ ChangeEmail,
+
+ ///
+ /// Represents a user code type used for password reset actions.
+ ///
+ PasswordReset,
+}
+
+///
+/// Provides extension methods for the enum to convert it to database values.
+///
+public static class UserCodeTypeExtensions
+{
+ ///
+ /// Converts the enum value to its corresponding database string representation.
+ ///
+ /// The enum value to convert.
+ /// The database string representation of the enum value.
+ /// Thrown when the value is not recognized.
+ public static string ToDbValue(this UserCodeType codeType)
+ {
+ return codeType switch {
+ UserCodeType.ChangeEmail => "changeemail",
+ UserCodeType.PasswordReset => "PasswordReset",
+ _ => throw new ArgumentOutOfRangeException(nameof(codeType), codeType, null)
+ };
+ }
+}
diff --git a/src/Dfe.SignIn.Core.Contracts/Features/Users/UsersApiRoutes.cs b/src/Dfe.SignIn.Core.Contracts/Features/Users/UsersApiRoutes.cs
index e1b2ec79..fdb1418e 100644
--- a/src/Dfe.SignIn.Core.Contracts/Features/Users/UsersApiRoutes.cs
+++ b/src/Dfe.SignIn.Core.Contracts/Features/Users/UsersApiRoutes.cs
@@ -39,4 +39,9 @@ public static class UsersApiRoutes
/// The API route for confirming the change of a user's email address.
///
public const string ConfirmChangeEmail = "/internal/users/{userId}/confirm-change-email";
+
+ ///
+ /// The API route for cancelling the change of a user's email address.
+ ///
+ public const string CancelChangeEmail = "/internal/users/{userId}/cancel-change-email";
}
diff --git a/src/Dfe.SignIn.Core.Contracts/Users/CancelPendingChangeEmailAddress.cs b/src/Dfe.SignIn.Core.Contracts/Users/CancelPendingChangeEmailAddress.cs
deleted file mode 100644
index 69c1a9ac..00000000
--- a/src/Dfe.SignIn.Core.Contracts/Users/CancelPendingChangeEmailAddress.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using Dfe.SignIn.Base.Framework;
-using Dfe.SignIn.Core.Contracts.Features.Users.Exceptions;
-
-namespace Dfe.SignIn.Core.Contracts.Users;
-
-///
-/// Represents a request to cancel a previous user request to change their email address.
-///
-[AssociatedResponse(typeof(CancelPendingChangeEmailAddressResponse))]
-[Throws(typeof(UserNotFoundException))]
-public sealed record CancelPendingChangeEmailAddressRequest
-{
- ///
- /// The unique ID of the user.
- ///
- public Guid UserId { get; init; }
-}
-
-///
-/// Represents a response for .
-///
-public sealed record CancelPendingChangeEmailAddressResponse
-{
-}
diff --git a/src/Dfe.SignIn.Core.Entities/Directories/UserCodeEntity.cs b/src/Dfe.SignIn.Core.Entities/Directories/UserCodeEntity.cs
index d5d7f1b8..ee01eb7e 100644
--- a/src/Dfe.SignIn.Core.Entities/Directories/UserCodeEntity.cs
+++ b/src/Dfe.SignIn.Core.Entities/Directories/UserCodeEntity.cs
@@ -24,5 +24,3 @@ public partial class UserCodeEntity
public string? ContextData { get; set; }
}
-#pragma warning restore CS1591
-
diff --git a/src/Dfe.SignIn.InternalApi/Endpoints/EndpointRegistry.cs b/src/Dfe.SignIn.InternalApi/Endpoints/EndpointRegistry.cs
new file mode 100644
index 00000000..1aa02968
--- /dev/null
+++ b/src/Dfe.SignIn.InternalApi/Endpoints/EndpointRegistry.cs
@@ -0,0 +1,45 @@
+namespace Dfe.SignIn.InternalApi.Endpoints;
+
+///
+/// A compile-time safe registry for class-based Minimal API endpoints.
+/// Handles both Dependency Injection registration and HTTP route mapping
+/// using strongly-typed generics (no reflection).
+///
+public sealed class EndpointRegistry
+{
+ private readonly List<(Type Type, ServiceLifetime Lifetime)> endpoints = [];
+ private readonly List> mappers = [];
+
+ ///
+ /// Registers an endpoint class for DI and captures its static Map delegate.
+ ///
+ /// The endpoint class implementing .
+ /// The DI service lifetime. Defaults to Transient.
+ public EndpointRegistry Add(ServiceLifetime lifetime = ServiceLifetime.Transient)
+ where T : class, IEndpoint
+ {
+ this.endpoints.Add((typeof(T), lifetime));
+ this.mappers.Add(T.Map);
+ return this;
+ }
+
+ ///
+ /// Registers all added endpoint classes with the DI container.
+ ///
+ public void RegisterServices(IServiceCollection services)
+ {
+ foreach (var (type, lifetime) in this.endpoints) {
+ services.Add(new ServiceDescriptor(type, type, lifetime));
+ }
+ }
+
+ ///
+ /// Maps all added endpoint routes to the application.
+ ///
+ public void MapRoutes(IEndpointRouteBuilder app)
+ {
+ foreach (var map in this.mappers) {
+ map(app);
+ }
+ }
+}
diff --git a/src/Dfe.SignIn.InternalApi/IEndpoint.cs b/src/Dfe.SignIn.InternalApi/Endpoints/IEndpoint.cs
similarity index 90%
rename from src/Dfe.SignIn.InternalApi/IEndpoint.cs
rename to src/Dfe.SignIn.InternalApi/Endpoints/IEndpoint.cs
index 39bd2d7c..8066282d 100644
--- a/src/Dfe.SignIn.InternalApi/IEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Endpoints/IEndpoint.cs
@@ -1,4 +1,4 @@
-namespace Dfe.SignIn.InternalApi;
+namespace Dfe.SignIn.InternalApi.Endpoints;
///
/// Defines a contract for an endpoint that can be mapped to an .
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/CancelChangeEmailAddressEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/CancelChangeEmailAddressEndpoint.cs
new file mode 100644
index 00000000..8831d02e
--- /dev/null
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/CancelChangeEmailAddressEndpoint.cs
@@ -0,0 +1,76 @@
+using Dfe.SignIn.Core.Contracts.Audit;
+using Dfe.SignIn.Core.Contracts.Features.Users;
+using Dfe.SignIn.Core.Contracts.Features.Users.Shared;
+using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Dfe.SignIn.InternalApi.Features.Users.ChangeEmail;
+
+///
+/// An endpoint to cancel the change of a user's email address.
+///
+public sealed class CancelChangeEmailAddressEndpoint(
+ DbDirectoriesContext directoriesDbContext,
+ IAuditWriter auditWriter,
+ ILogger logger,
+ IUserLookupService userLookupService) : IEndpoint
+{
+ ///
+ /// Maps the endpoint to the specified .
+ ///
+ /// The endpoint route builder to map the endpoint to.
+ public static void Map(IEndpointRouteBuilder app)
+ {
+ app.MapDelete(UsersApiRoutes.CancelChangeEmail, async (
+ [FromRoute] Guid userId,
+ [FromServices] CancelChangeEmailAddressEndpoint endpoint,
+ CancellationToken cancellationToken) =>
+ await endpoint.HandleAsync(userId, cancellationToken))
+ .WithName("Cancel Change Email Address")
+ .WithTags("Users")
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status404NotFound)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .WithOpenApi();
+ }
+
+ ///
+ /// Handles the cancellation of a user's email address change request.
+ ///
+ public async Task HandleAsync(
+ Guid userId,
+ CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Cancelling email change for user {UserId}", userId);
+
+ var userInfo = await userLookupService.GetUserInfoAsync(userId, cancellationToken);
+
+ if (userInfo is null) {
+ logger.LogWarning("User {UserId} not found", userId);
+ return Results.NotFound(new { Message = "User not found" });
+ }
+
+ await auditWriter.Log(new WriteToAuditRequest {
+ EventCategory = AuditEventCategoryNames.ChangeEmail,
+ EventName = AuditChangeEmailEventNames.CancelChangeEmail,
+ Message = $"Cancel change email request from {userInfo.EmailAddress} (id: {userId})",
+ });
+
+ try {
+ await directoriesDbContext.UserCodes
+ .Where(uc => uc.Uid == userId)
+ .Where(uc => uc.CodeType == UserCodeType.ChangeEmail.ToDbValue())
+ .ExecuteDeleteAsync(cancellationToken);
+ }
+ catch (Exception ex) {
+ logger.LogError(ex, "Error cancelling email change for user {UserId}", userId);
+ throw;
+ }
+
+ logger.LogInformation("Successfully cancelled email change for user {UserId}", userId);
+ return Results.Ok();
+ }
+}
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/ConfirmChangeEmailAddressEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/ConfirmChangeEmailAddressEndpoint.cs
index 02d30f9e..124bef34 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/ConfirmChangeEmailAddressEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/ConfirmChangeEmailAddressEndpoint.cs
@@ -7,6 +7,7 @@
using Dfe.SignIn.Core.Interfaces.ExternalAuth;
using Dfe.SignIn.Core.Interfaces.Notifications;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Dfe.SignIn.InternalApi.Features.Users.UserCode;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/InitiateChangeEmailAddressEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/InitiateChangeEmailAddressEndpoint.cs
index 1ca9f5b2..02e1f00a 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/InitiateChangeEmailAddressEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeEmail/InitiateChangeEmailAddressEndpoint.cs
@@ -3,6 +3,7 @@
using Dfe.SignIn.Core.Contracts.Features.Users;
using Dfe.SignIn.Core.Contracts.Features.Users.ChangeEmailAddress;
using Dfe.SignIn.Gateways.DistributedCache.Interactions;
+using Dfe.SignIn.InternalApi.Endpoints;
using Dfe.SignIn.InternalApi.Features.Users.UserCode;
using Humanizer;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeJobTitle/ChangeJobTitleEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeJobTitle/ChangeJobTitleEndpoint.cs
index edd06978..0dcc1052 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeJobTitle/ChangeJobTitleEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeJobTitle/ChangeJobTitleEndpoint.cs
@@ -3,6 +3,7 @@
using Dfe.SignIn.Core.Contracts.Features.Users;
using Dfe.SignIn.Core.Contracts.Features.Users.ChangeJobTitle;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeName/ChangeNameEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeName/ChangeNameEndpoint.cs
index 195abb93..0fc1951a 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/ChangeName/ChangeNameEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/ChangeName/ChangeNameEndpoint.cs
@@ -3,6 +3,7 @@
using Dfe.SignIn.Core.Contracts.Features.Users;
using Dfe.SignIn.Core.Contracts.Features.Users.ChangeName;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/GetUserProfile/GetUserProfileEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/GetUserProfile/GetUserProfileEndpoint.cs
index afe9456c..9a6252b0 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/GetUserProfile/GetUserProfileEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/GetUserProfile/GetUserProfileEndpoint.cs
@@ -1,6 +1,7 @@
using Dfe.SignIn.Core.Contracts.Features.Users;
using Dfe.SignIn.Core.Contracts.Features.Users.GetUserProfile;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/IsApprover/IsApproverEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/IsApprover/IsApproverEndpoint.cs
index bfd680b8..b623c8f7 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/IsApprover/IsApproverEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/IsApprover/IsApproverEndpoint.cs
@@ -3,6 +3,7 @@
using Dfe.SignIn.Core.Contracts.Organisations;
using Dfe.SignIn.Core.Contracts.Users;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Dfe.SignIn.InternalApi.Features.Users.ChangeName;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/PendingApprovalCounter/PendingApprovalCounterEndpoint.cs b/src/Dfe.SignIn.InternalApi/Features/Users/PendingApprovalCounter/PendingApprovalCounterEndpoint.cs
index 7036d3ab..00ea96bc 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/PendingApprovalCounter/PendingApprovalCounterEndpoint.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/PendingApprovalCounter/PendingApprovalCounterEndpoint.cs
@@ -2,6 +2,7 @@
using Dfe.SignIn.Core.Contracts.Organisations;
using Dfe.SignIn.Core.Contracts.Users;
using Dfe.SignIn.Gateways.EntityFramework;
+using Dfe.SignIn.InternalApi.Endpoints;
using Microsoft.EntityFrameworkCore;
namespace Dfe.SignIn.InternalApi.Features.Users.PendingApprovalCounter;
diff --git a/src/Dfe.SignIn.InternalApi/Features/Users/UsersFeature.cs b/src/Dfe.SignIn.InternalApi/Features/Users/UsersFeature.cs
index f9f2b938..f2b74979 100644
--- a/src/Dfe.SignIn.InternalApi/Features/Users/UsersFeature.cs
+++ b/src/Dfe.SignIn.InternalApi/Features/Users/UsersFeature.cs
@@ -5,6 +5,7 @@
using Dfe.SignIn.Core.Interfaces.Notifications;
using Dfe.SignIn.Core.UseCases.Users;
using Dfe.SignIn.Gateways.DistributedCache.Interactions;
+using Dfe.SignIn.InternalApi.Endpoints;
using Dfe.SignIn.InternalApi.Features.Users.ChangeEmail;
using Dfe.SignIn.InternalApi.Features.Users.ChangeJobTitle;
using Dfe.SignIn.InternalApi.Features.Users.ChangeName;
@@ -21,6 +22,9 @@ namespace Dfe.SignIn.InternalApi.Features.Users;
[ExcludeFromCodeCoverage]
public static class UsersFeature
{
+ private static readonly EndpointRegistry NewEndpointRegistry = new EndpointRegistry()
+ .Add();
+
///
/// Maps the user-related endpoints to the specified .
///
@@ -34,6 +38,9 @@ public static void MapUsersEndpoints(this IEndpointRouteBuilder app)
IsApproverEndpoint.Map(app);
PendingApprovalCounterEndpoint.Map(app);
ChangeJobTitleEndpoint.Map(app);
+
+ // New class-based endpoint mapped via registry
+ NewEndpointRegistry.MapRoutes(app);
}
///
@@ -50,6 +57,10 @@ public static IServiceCollection AddUserServices(this IServiceCollection service
services.AddScoped();
services.AddInteractionLimiter(configuration);
+
+ // Register class-based endpoints with DI
+ NewEndpointRegistry.RegisterServices(services);
+
return services;
}
}
diff --git a/src/Dfe.SignIn.NodeApi.Client/Users/CancelPendingChangeEmailAddressNodeRequester.cs b/src/Dfe.SignIn.NodeApi.Client/Users/CancelPendingChangeEmailAddressNodeRequester.cs
deleted file mode 100644
index c92e0ee9..00000000
--- a/src/Dfe.SignIn.NodeApi.Client/Users/CancelPendingChangeEmailAddressNodeRequester.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using Dfe.SignIn.Base.Framework;
-using Dfe.SignIn.Core.Contracts.Audit;
-using Dfe.SignIn.Core.Contracts.Features.Users;
-using Dfe.SignIn.Core.Contracts.Users;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Dfe.SignIn.NodeApi.Client.Users;
-
-///
-/// An interactor to get information about a pending user request to change their
-/// email address.
-///
-[ApiRequester, NodeApi(NodeApiName.Directories)]
-public sealed class CancelPendingChangeEmailAddressNodeRequester(
- [FromKeyedServices(NodeApiName.Directories)] HttpClient directoriesClient,
- IInteractionDispatcher interaction,
- IUserLookupService userLookupService
-) : Interactor
-{
- ///
- public override async Task InvokeAsync(
- InteractionContext context,
- CancellationToken cancellationToken = default)
- {
- context.ThrowIfHasValidationErrors();
-
- var userInfo = await userLookupService.GetUserInfoAsync(context.Request.UserId, cancellationToken);
-
- await interaction.DispatchAsync(
- new WriteToAuditRequest {
- EventCategory = AuditEventCategoryNames.ChangeEmail,
- EventName = AuditChangeEmailEventNames.CancelChangeEmail,
- Message = $"Cancel change email request from {userInfo!.EmailAddress} (id: {context.Request.UserId})",
- }
- );
-
- string endpoint = $"usercodes/{context.Request.UserId}/changeemail";
- var response = await directoriesClient.DeleteAsync(endpoint, cancellationToken);
- response.EnsureSuccessStatusCode();
-
- return new CancelPendingChangeEmailAddressResponse();
- }
-}
diff --git a/src/Dfe.SignIn.Web.Profile/Controllers/ChangeEmailController.cs b/src/Dfe.SignIn.Web.Profile/Controllers/ChangeEmailController.cs
index b4cb9147..8b99499a 100644
--- a/src/Dfe.SignIn.Web.Profile/Controllers/ChangeEmailController.cs
+++ b/src/Dfe.SignIn.Web.Profile/Controllers/ChangeEmailController.cs
@@ -203,11 +203,7 @@ public IActionResult PostComplete()
[ValidateAntiForgeryToken]
public async Task PostCancel()
{
- await interaction.DispatchAsync(
- new CancelPendingChangeEmailAddressRequest {
- UserId = this.User.GetUserId(),
- }
- );
+ await usersApiClient.CancelChangeEmailAddress(this.User.GetUserId());
this.SetFlashNotification(
heading: "Email change cancelled",
diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeEmailAddress/CancelChangeChangeEmailTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeEmailAddress/CancelChangeChangeEmailTests.cs
new file mode 100644
index 00000000..f860e959
--- /dev/null
+++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeEmailAddress/CancelChangeChangeEmailTests.cs
@@ -0,0 +1,172 @@
+using System.Net;
+using Dfe.SignIn.Core.Contracts.Audit;
+using Dfe.SignIn.Core.Entities.Directories;
+using Dfe.SignIn.Gateways.EntityFramework;
+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.ChangeEmailAddress;
+
+[Trait("Category", "Integration")]
+public sealed class CancelChangeChangeEmailTests : InternalApiIntegrationEndpointTestBase
+{
+ private const string endpoint = "/internal/users/{userId}/cancel-change-email";
+
+ private static string GetEndpointForUser(Guid userId) => endpoint.Replace("{userId}", userId.ToString());
+
+ public CancelChangeChangeEmailTests(InternalApiWebApplicationFactory factory)
+ : base(factory)
+ {
+ }
+
+ [Fact]
+ public async Task CancelChangeEmail_ReturnsSuccess_WritesAudit_AndDeletesCode()
+ {
+ var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock();
+
+ var user = EntityFaker.User
+ .RuleFor(x => x.Email, (_, _) => "john.doe@old.example.com")
+ .Generate();
+
+ var pendingCode = EntityFaker.UserCode
+ .RuleFor(x => x.Uid, (_, _) => user.Sub)
+ .RuleFor(x => x.CodeType, (_, _) => "changeemail")
+ .RuleFor(x => x.Code, (_, _) => "ABC1234")
+ .RuleFor(x => x.Email, (_, _) => "john.doe@new.example.com")
+ .Generate();
+
+ await this.InsertEntityAsync(user);
+ await this.InsertEntityAsync(pendingCode);
+
+ var path = GetEndpointForUser(user.Sub);
+ var response = await authenticatedClient.DeleteAsync(path);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope();
+ var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService();
+
+ var dbCode = await GetPendingChangeEmailCode(assertionDbContext, user.Sub);
+ Assert.Null(dbCode);
+
+ Assert.NotNull(auditMock.CapturedRequest);
+ Assert.Equal(AuditEventCategoryNames.ChangeEmail, auditMock.CapturedRequest.EventCategory);
+ Assert.Equal(AuditChangeEmailEventNames.CancelChangeEmail, auditMock.CapturedRequest.EventName);
+ Assert.Contains(user.Sub.ToString(), auditMock.CapturedRequest.Message);
+ }
+
+ [Fact]
+ public async Task CancelChangeEmail_Returns401_WhenUnauthenticated()
+ {
+ var anonymousClient = this.CreateClient();
+ var userId = Guid.NewGuid();
+
+ var response = await anonymousClient.PostAsync(GetEndpointForUser(userId), new StringContent(string.Empty));
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CancelChangeEmail_DeletesOnlyTargetPendingCode_AndLeavesUserEmailUnchanged()
+ {
+ var authenticatedClient = this.CreateClient().WithAuthentication();
+
+ var targetUser = EntityFaker.User
+ .RuleFor(x => x.Email, (_, _) => "target.old@example.com")
+ .Generate();
+
+ var otherUser = EntityFaker.User
+ .RuleFor(x => x.Email, (_, _) => "other@example.com")
+ .Generate();
+
+ var targetCode = EntityFaker.UserCode
+ .RuleFor(x => x.Uid, (_, _) => targetUser.Sub)
+ .RuleFor(x => x.CodeType, (_, _) => "changeemail")
+ .RuleFor(x => x.Code, (_, _) => "TARGET123")
+ .RuleFor(x => x.Email, (_, _) => "target.new@example.com")
+ .Generate();
+
+ var otherCode = EntityFaker.UserCode
+ .RuleFor(x => x.Uid, (_, _) => otherUser.Sub)
+ .RuleFor(x => x.CodeType, (_, _) => "changeemail")
+ .RuleFor(x => x.Code, (_, _) => "OTHER123")
+ .RuleFor(x => x.Email, (_, _) => "other.new@example.com")
+ .Generate();
+
+ await this.InsertEntitiesAsync([targetUser, otherUser]);
+ await this.InsertEntityAsync(targetCode);
+ await this.InsertEntityAsync(otherCode);
+
+ var response = await authenticatedClient.DeleteAsync(GetEndpointForUser(targetUser.Sub));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope();
+ var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService();
+
+ var targetUserRow = await assertionDbContext.Users.SingleAsync(x => x.Sub == targetUser.Sub);
+ Assert.Equal("target.old@example.com", targetUserRow.Email);
+
+ var targetPending = await GetPendingChangeEmailCode(assertionDbContext, targetUser.Sub);
+ Assert.Null(targetPending);
+
+ var otherPending = await GetPendingChangeEmailCode(assertionDbContext, otherUser.Sub);
+ Assert.NotNull(otherPending);
+ Assert.Equal("other.new@example.com", otherPending.Email);
+ }
+
+ [Fact]
+ public async Task CancelChangeEmail_DoesNotWriteSuccessPathAudit_WhenDeleteFails()
+ {
+ var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock();
+
+ var user = EntityFaker.User
+ .RuleFor(x => x.Email, (_, _) => "john.doe@old.example.com")
+ .Generate();
+
+ var pendingCode = EntityFaker.UserCode
+ .RuleFor(x => x.Uid, (_, _) => user.Sub)
+ .RuleFor(x => x.CodeType, (_, _) => "changeemail")
+ .RuleFor(x => x.Code, (_, _) => "ABC1234")
+ .RuleFor(x => x.Email, (_, _) => "john.doe@new.example.com")
+ .Generate();
+
+ await this.InsertEntityAsync(user);
+ await this.InsertEntityAsync(pendingCode);
+
+ this.TestTimestampInterceptor.ShouldFail = true;
+
+ var response = await authenticatedClient.PostAsync(GetEndpointForUser(user.Sub), new StringContent(string.Empty));
+
+ Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.DoesNotContain(auditMock.CapturedRequests, x => x.EventName == AuditChangeEmailEventNames.CancelChangeEmail && !x.WasFailure);
+ }
+
+ [Fact]
+ public async Task CancelChangeEmail_ReturnsSuccess_WhenNoPendingCodeExists()
+ {
+ var authenticatedClient = this.CreateClient().WithAuthentication();
+
+ var user = EntityFaker.User
+ .RuleFor(x => x.Email, (_, _) => "john.doe@old.example.com")
+ .Generate();
+
+ await this.InsertEntityAsync(user);
+
+ var response = await authenticatedClient.DeleteAsync(GetEndpointForUser(user.Sub));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope();
+ var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService();
+
+ var dbCode = await GetPendingChangeEmailCode(assertionDbContext, user.Sub);
+ Assert.Null(dbCode);
+ }
+
+ private static async Task GetPendingChangeEmailCode(DbDirectoriesContext dbContext, Guid userId)
+ => await dbContext.UserCodes.SingleOrDefaultAsync(x => x.Uid == userId && x.CodeType == "changeemail");
+}
diff --git a/tests/Dfe.SignIn.NodeApi.Client.UnitTests/Users/CancelPendingChangeEmailAddressNodeRequesterTests.cs b/tests/Dfe.SignIn.NodeApi.Client.UnitTests/Users/CancelPendingChangeEmailAddressNodeRequesterTests.cs
deleted file mode 100644
index 62f5f2b5..00000000
--- a/tests/Dfe.SignIn.NodeApi.Client.UnitTests/Users/CancelPendingChangeEmailAddressNodeRequesterTests.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System.Net;
-using Dfe.SignIn.Base.Framework;
-using Dfe.SignIn.Core.Contracts.Audit;
-using Dfe.SignIn.Core.Contracts.Features.Users;
-using Dfe.SignIn.Core.Contracts.Features.Users.Shared;
-using Dfe.SignIn.Core.Contracts.Users;
-using Dfe.SignIn.NodeApi.Client.Users;
-using Moq;
-using Moq.AutoMock;
-
-namespace Dfe.SignIn.NodeApi.Client.UnitTests.Users;
-
-[TestClass]
-public sealed class CancelPendingChangeEmailAddressNodeRequesterTests
-{
- [TestMethod]
- public Task Throws_WhenRequestIsInvalid()
- {
- return InteractionAssert.ThrowsWhenRequestIsInvalid<
- CancelPendingChangeEmailAddressRequest,
- CancelPendingChangeEmailAddressNodeRequester
- >();
- }
-
- private static CancelPendingChangeEmailAddressNodeRequester CreateCancelPendingChangeEmailAddressNodeRequester(
- AutoMocker autoMocker,
- Dictionary responseMappings)
- {
- var directoriesHandlerMock = HttpClientMocking.GetHandlerWithMappedResponses(responseMappings);
- var directoriesClient = new HttpClient(directoriesHandlerMock.Object) {
- BaseAddress = new Uri("http://directories.localhost")
- };
-
- var userLookupServiceMock = autoMocker.GetMock();
- var userId = new Guid("51a50a75-e4fa-4b6e-9c72-581538ee5258");
-
- userLookupServiceMock
- .Setup(x => x.GetUserInfoAsync(userId, CancellationToken.None))
- .ReturnsAsync(new UserInfo(
- userId,
- "alex.johnson@example.com",
- "Alex",
- "Johnson",
- AccountStatus.Active));
-
- return new CancelPendingChangeEmailAddressNodeRequester(
- directoriesClient,
- autoMocker.Get(),
- userLookupServiceMock.Object
- );
- }
-
- private static Dictionary GetNodeResponseMappingsForHappyPath()
- {
- return new() {
- // Directories API
- ["(DELETE) http://directories.localhost/usercodes/51a50a75-e4fa-4b6e-9c72-581538ee5258/changeemail"] =
- new MappedResponse(HttpStatusCode.OK),
- };
- }
-
- [TestMethod]
- public async Task WritesToAudit()
- {
- var autoMocker = new AutoMocker();
-
- WriteToAuditRequest? capturedWriteToAudit = null;
- autoMocker.CaptureRequest(request => capturedWriteToAudit = request);
-
- var responseMappings = GetNodeResponseMappingsForHappyPath();
- var interactor = CreateCancelPendingChangeEmailAddressNodeRequester(autoMocker, responseMappings);
-
- await interactor.InvokeAsync(new CancelPendingChangeEmailAddressRequest {
- UserId = new Guid("51a50a75-e4fa-4b6e-9c72-581538ee5258"),
- });
-
- Assert.IsNotNull(capturedWriteToAudit);
- Assert.AreEqual(AuditEventCategoryNames.ChangeEmail, capturedWriteToAudit.EventCategory);
- Assert.AreEqual(AuditChangeEmailEventNames.CancelChangeEmail, capturedWriteToAudit.EventName);
- Assert.AreEqual("Cancel change email request from alex.johnson@example.com (id: 51a50a75-e4fa-4b6e-9c72-581538ee5258)", capturedWriteToAudit.Message);
- }
-
- [TestMethod]
- public async Task MakesExpectedRequestToCancelPendingChangeEmailAddress()
- {
- var autoMocker = new AutoMocker();
- var responseMappings = GetNodeResponseMappingsForHappyPath();
- var interactor = CreateCancelPendingChangeEmailAddressNodeRequester(autoMocker, responseMappings);
-
- var response = await interactor.InvokeAsync(new CancelPendingChangeEmailAddressRequest {
- UserId = new Guid("51a50a75-e4fa-4b6e-9c72-581538ee5258"),
- });
-
- Assert.IsNotNull(response);
-
- var mapping = responseMappings["(DELETE) http://directories.localhost/usercodes/51a50a75-e4fa-4b6e-9c72-581538ee5258/changeemail"];
- Assert.HasCount(1, mapping.Invocations);
- }
-
- [TestMethod]
- public async Task Throws_WhenRequestFails()
- {
- var interactor = CreateCancelPendingChangeEmailAddressNodeRequester(new AutoMocker(), new() {
- ["(DELETE) http://directories.localhost/usercodes/51a50a75-e4fa-4b6e-9c72-581538ee5258/changeemail"] =
- new MappedResponse(HttpStatusCode.BadRequest),
- });
-
- await Assert.ThrowsExactlyAsync(()
- => interactor.InvokeAsync(new CancelPendingChangeEmailAddressRequest {
- UserId = new Guid("51a50a75-e4fa-4b6e-9c72-581538ee5258"),
- }));
- }
-}
diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs
index 48e45527..83174a3e 100644
--- a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs
+++ b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs
@@ -30,4 +30,15 @@ public static class EntityFaker
.RuleFor(x => x.Status, _ => (int)OrganisationStatus.Open)
.RuleFor(x => x.CreatedAt, f => f.Date.Past(2))
.RuleFor(x => x.UpdatedAt, (f, org) => f.Date.Between(org.CreatedAt, DateTime.UtcNow));
+
+ public static Faker UserCode => new Faker()
+ .RuleFor(x => x.Uid, f => f.Random.Guid())
+ .RuleFor(x => x.CodeType, _ => "changeemail")
+ .RuleFor(x => x.Code, f => f.Random.AlphaNumeric(7))
+ .RuleFor(x => x.Email, f => f.Internet.Email())
+ .RuleFor(x => x.ClientId, _ => "test-client")
+ .RuleFor(x => x.RedirectUri, _ => "n/a")
+ .RuleFor(x => x.ContextData, _ => null)
+ .RuleFor(x => x.CreatedAt, f => f.Date.Past(2))
+ .RuleFor(x => x.UpdatedAt, (f, code) => f.Date.Between(code.CreatedAt, DateTime.UtcNow));
}
diff --git a/tests/Dfe.SignIn.Web.Profile.UnitTests/Controllers/ChangeEmailControllerTests.cs b/tests/Dfe.SignIn.Web.Profile.UnitTests/Controllers/ChangeEmailControllerTests.cs
index 75763748..70bfcb16 100644
--- a/tests/Dfe.SignIn.Web.Profile.UnitTests/Controllers/ChangeEmailControllerTests.cs
+++ b/tests/Dfe.SignIn.Web.Profile.UnitTests/Controllers/ChangeEmailControllerTests.cs
@@ -612,22 +612,6 @@ public void PostComplete_RedirectsToHome()
#region PostCancel()
- [TestMethod]
- public async Task PostCancel_CancelsPendingChange()
- {
- var autoMocker = new AutoMocker();
-
- Core.Contracts.Users.CancelPendingChangeEmailAddressRequest? capturedRequest = null;
- autoMocker.CaptureRequest(req => capturedRequest = req);
-
- var controller = CreateControllerAuthenticated(autoMocker);
-
- await controller.PostCancel();
-
- Assert.IsNotNull(capturedRequest);
- Assert.AreEqual(new Guid("15eb0a65-2d08-4f96-8dc9-9d77798e6c54"), capturedRequest.UserId);
- }
-
[TestMethod]
public async Task PostCancel_FlashCancelled()
{