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() {