diff --git a/.gitignore b/.gitignore index 70fa819d..94c46e49 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,6 @@ backend.vars /Benchmarks/Dfe.PersonsApi.Benchmarks/BenchmarkDotNet.Artifacts/results /uploads/ uploads/ + +Directory.Build.targets.user +/src/LocalPackages \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3570e54a..11343c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this service will be documented in this file. ### Notes - FlexForms (Forms Engine SaaS) +## [2.1.0] - Application delete functionality +### Notes +- Added functionality to soft delete an application, which will be marked with the deleted status in the DB + --------------------------------------------------------------------------- @@ -140,3 +144,4 @@ All notable changes to this service will be documented in this file. ## [1.5.2] - Filter on status fix ### Notes - Fix for the filtering not working when a null value for status is encountered in the db + diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs index 3cbff283..c74deb2c 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Client.g.cs @@ -951,6 +951,144 @@ public string BaseUrl } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Soft deletes an application, changing its status to Deleted. + /// + /// Application deleted successfully. + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task DeleteApplicationAsync(System.Guid applicationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (applicationId == null) + throw new System.ArgumentNullException("applicationId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "v1/Applications/{applicationId}" + urlBuilder_.Append("v1/Applications/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(applicationId, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Invalid request data or application not found.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Unauthorized - no valid user token", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 403) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("User does not have permission to delete this application", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Application not found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 429) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Too Many Requests.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ExternalApplicationsException("Internal server error.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Submits an application, changing its status to Submitted. diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs index 1e155090..8cd5ddb8 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/Contracts.g.cs @@ -100,6 +100,14 @@ public partial interface IApplicationsClient /// A server side error occurred. System.Threading.Tasks.Task GetApplicationByReferenceAsync(string applicationReference, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Soft deletes an application, changing its status to Deleted. + /// + /// Application deleted successfully. + /// A server side error occurred. + System.Threading.Tasks.Task DeleteApplicationAsync(System.Guid applicationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Submits an application, changing its status to Submitted. diff --git a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json index c99fad1c..387a3708 100644 --- a/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json +++ b/src/GovUK.Dfe.FlexForms.Api.Client/Generated/swagger.json @@ -714,6 +714,99 @@ } } }, + "/v1/Applications/{applicationId}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Soft deletes an application, changing its status to Deleted.", + "operationId": "Applications_DeleteApplication", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "guid" + }, + "x-position": 1 + } + ], + "responses": { + "200": { + "description": "Application deleted successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationDto" + } + } + } + }, + "400": { + "description": "Invalid request data or application not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - no valid user token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, + "403": { + "description": "User does not have permission to delete this application", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, + "404": { + "description": "Application not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, + "429": { + "description": "Too Many Requests.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + } + } + } + }, "/v1/Applications/{applicationId}/submit": { "post": { "tags": [ diff --git a/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs b/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs index df3204c5..6a55f0b8 100644 --- a/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs +++ b/src/GovUK.Dfe.FlexForms.Api/Controllers/ApplicationsController.cs @@ -1,18 +1,19 @@ using Asp.Versioning; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; +using GovUK.Dfe.FlexForms.Api.Filters; using GovUK.Dfe.FlexForms.Api.Models.Applications; using GovUK.Dfe.FlexForms.Application.Applications.Commands; using GovUK.Dfe.FlexForms.Application.Applications.Queries; using GovUK.Dfe.FlexForms.Application.Common.Exceptions; -using GovUK.Dfe.FlexForms.Api.Filters; +using GovUK.Dfe.FlexForms.Infrastructure.Security; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId; -using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; namespace GovUK.Dfe.FlexForms.Api.Controllers; @@ -167,6 +168,31 @@ public async Task GetApplicationByReferenceAsync( }; } + /// + /// Soft deletes an application, changing its status to Deleted. + /// + [HttpDelete("{applicationId}")] + [SwaggerResponse(200, "Application deleted successfully.", typeof(ApplicationDto))] + [SwaggerResponse(400, "Invalid request data or application not found.", typeof(ExceptionResponse))] + [SwaggerResponse(401, "Unauthorized - no valid user token", typeof(ExceptionResponse))] + [SwaggerResponse(403, "User does not have permission to delete this application", typeof(ExceptionResponse))] + [SwaggerResponse(404, "Application not found", typeof(ExceptionResponse))] + [SwaggerResponse(500, "Internal server error.", typeof(ExceptionResponse))] + [SwaggerResponse(429, "Too Many Requests.", typeof(ExceptionResponse))] + [Authorize(Policy = AuthConstants.TenantAdminUserPolicy)] + public async Task DeleteApplicationAsync( + [FromRoute] Guid applicationId, + CancellationToken cancellationToken) + { + var command = new DeleteApplicationCommand(applicationId); + var result = await sender.Send(command, cancellationToken); + + return new ObjectResult(result) + { + StatusCode = StatusCodes.Status200OK + }; + } + /// /// Submits an application, changing its status to Submitted. /// diff --git a/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj b/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj index 2c0bbd2f..b30895cf 100644 --- a/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj +++ b/src/GovUK.Dfe.FlexForms.Api/GovUK.Dfe.FlexForms.Api.csproj @@ -74,5 +74,8 @@ - + + + + diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs new file mode 100644 index 00000000..725fa520 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandHandler.cs @@ -0,0 +1,89 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Applications.QueryObjects; +using GovUK.Dfe.FlexForms.Application.Common.Attributes; +using GovUK.Dfe.FlexForms.Application.Common.Behaviours; +using GovUK.Dfe.FlexForms.Application.Services; +using GovUK.Dfe.FlexForms.Domain.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories; +using GovUK.Dfe.FlexForms.Domain.Services; +using MediatR; +using Microsoft.EntityFrameworkCore; +using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId; + +namespace GovUK.Dfe.FlexForms.Application.Applications.Commands; + +[RateLimit(1, 30)] +public sealed record DeleteApplicationCommand(Guid ApplicationId) : IRequest>, IRateLimitedRequest; + +public sealed class DeleteApplicationCommandHandler( + IEaRepository applicationRepo, + IAuthenticatedUserService authenticatedUserService, + IPermissionCheckerService permissionCheckerService, + IUserCacheInvalidator userCacheInvalidator, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + public async Task> Handle( + DeleteApplicationCommand request, + CancellationToken cancellationToken) + { + try + { + var currentUserResult = await authenticatedUserService.GetCurrentUserAsync(cancellationToken); + if (!currentUserResult.IsSuccess) + { + return currentUserResult.ErrorCode switch + { + DomainErrorCode.NotFound => Result.NotFound(currentUserResult.Error!), + DomainErrorCode.Forbidden => Result.Forbid(currentUserResult.Error!), + _ => Result.Failure(currentUserResult.Error!) + }; + } + + var dbUser = currentUserResult.Value!; + var canAccess = permissionCheckerService.HasPermission( + ResourceType.Application, + request.ApplicationId.ToString(), + AccessType.Write); + + if (!canAccess) + return Result.Forbid("User does not have permission to delete this application"); + + var applicationId = new ApplicationId(request.ApplicationId); + var application = await (new GetApplicationByIdQueryObject(applicationId)) + .Apply(applicationRepo.Query()) + .FirstOrDefaultAsync(cancellationToken); + + if (application is null) + return Result.NotFound("Application not found"); + + var now = DateTime.UtcNow; + application.Delete(now, dbUser.Id!, dbUser.Email, dbUser.Name); + + await unitOfWork.CommitAsync(cancellationToken); + + await userCacheInvalidator.InvalidateForUserAsync( + dbUser.Email, + dbUser.ExternalProviderId, + dbUser.Id!, + cancellationToken); + + return Result.Success(new ApplicationDto + { + ApplicationId = application.Id!.Value, + ApplicationReference = application.ApplicationReference, + TemplateVersionId = application.TemplateVersionId.Value, + TemplateName = application.TemplateVersion?.Template?.Name ?? string.Empty, + Status = application.Status, + DateCreated = application.CreatedOn, + DateDeleted = application.LastModifiedOn, + LatestResponse = null, + TemplateSchema = null + }); + } + catch (Exception e) + { + return Result.Failure(e.Message); + } + } +} \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs new file mode 100644 index 00000000..69706be1 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Commands/DeleteApplicationCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GovUK.Dfe.FlexForms.Application.Tests")] +namespace GovUK.Dfe.FlexForms.Application.Applications.Commands; + +internal class DeleteApplicationCommandValidator : AbstractValidator +{ + public DeleteApplicationCommandValidator() + { + RuleFor(x => x.ApplicationId) + .NotEmpty() + .WithMessage("Application ID is required"); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs index 08c17c3a..f66b6913 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationByReferenceQueryHandler.cs @@ -47,6 +47,11 @@ public async Task> Handle( return Result.Forbid("Application does not belong to the current tenant"); } + if (dto.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin()) + { + return Result.NotFound("Application not found"); + } + var canAccess = permissionCheckerService.HasPermission( ResourceType.Application, dto.ApplicationId.ToString(), diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs index 5b8344bc..0772e33c 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsByTemplateQueryHandler.cs @@ -35,11 +35,11 @@ public sealed class GetApplicationsByTemplateQueryHandler( IHttpContextAccessor httpContextAccessor, IEaRepository userRepo, IEaRepository appRepo, + IPermissionCheckerService permissionCheckerService, IApplicationRepository applicationRepository, ICacheService cacheService, ITenantContextAccessor tenantContextAccessor, - ITenantTemplateResolver tenantTemplateResolver, - IPermissionCheckerService permissionCheckerService) + ITenantTemplateResolver tenantTemplateResolver) : IRequestHandler>> { public async Task>> Handle( @@ -100,7 +100,7 @@ public async Task>> Handle( query = ApplicationListingQueryBuilder.ApplySearchFilters( query, request.Search, - excludeStatus: request.Search?.Status is not null); + excludeStatus: (request.Search?.Status is not null) || (request.Search?.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin())); var pagedResult = await ApplicationListingQueryBuilder.MapPagedResultAsync( query, diff --git a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs index 15c51625..0e8358aa 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs @@ -7,6 +7,7 @@ using GovUK.Dfe.FlexForms.Application.Users.QueryObjects; using GovUK.Dfe.FlexForms.Domain.Entities; using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories; +using GovUK.Dfe.FlexForms.Domain.Services; using GovUK.Dfe.FlexForms.Domain.Tenancy; using MediatR; using Microsoft.EntityFrameworkCore; @@ -26,6 +27,7 @@ public sealed record GetApplicationsForUserQuery( public sealed class GetApplicationsForUserQueryHandler( IEaRepository userRepo, IEaRepository appRepo, + IPermissionCheckerService permissionCheckerService, IApplicationRepository applicationRepository, ICacheService cacheService, ITenantContextAccessor tenantContextAccessor, @@ -99,7 +101,7 @@ public async Task>> Handle( userWithAuthorization, templateIdsFilter); - query = ApplicationListingQueryBuilder.ApplySearchFilters(query, request.Search); + query = ApplicationListingQueryBuilder.ApplySearchFilters(query, request.Search, request.Search?.Status == ApplicationStatus.Deleted && !permissionCheckerService.IsAdmin()); var pagedResult = await ApplicationListingQueryBuilder.MapPagedResultAsync( query, diff --git a/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs b/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs index 70acd616..44fb2035 100644 --- a/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs +++ b/src/GovUK.Dfe.FlexForms.Domain/Entities/Application.cs @@ -20,6 +20,9 @@ public sealed class Application : BaseAggregateRoot, IEntity public User? CreatedByUser { get; private set; } public ApplicationStatus? Status { get; private set; } public DateTime? LastModifiedOn { get; private set; } + public DateTime? DeletedOn { get; private set; } = null; + public UserId? DeletedBy { get; private set; } + public User? DeletedByUser { get; private set; } public UserId? LastModifiedBy { get; private set; } public User? LastModifiedByUser { get; private set; } public IReadOnlyCollection Responses => _responses.AsReadOnly(); @@ -39,7 +42,9 @@ public Application( UserId createdBy, ApplicationStatus? status = null, DateTime? lastModifiedOn = null, - UserId? lastModifiedBy = null) + UserId? lastModifiedBy = null, + DateTime? deletedOn = null, + UserId? deletedBy = null) { Id = id ?? throw new ArgumentNullException(nameof(id)); ApplicationReference = applicationReference?.Trim() @@ -50,7 +55,10 @@ public Application( Status = status; LastModifiedOn = lastModifiedOn; LastModifiedBy = lastModifiedBy; - if(status is null) + DeletedOn = deletedOn; + DeletedBy = deletedBy; + + if (status is null) { Status = ApplicationStatus.Created; } @@ -118,4 +126,38 @@ public void Submit(DateTime submittedOn, UserId submittedBy, string userEmail, s userFullName, submittedOn)); } + + /// + /// Deletes the application, setting its status to Deleted and updating last modified tracking. + /// + public void Delete(DateTime deletedOn, UserId deletedBy, string userEmail, string userFullName) + { + if (deletedBy == null) + throw new ArgumentNullException(nameof(deletedBy)); + + if (string.IsNullOrWhiteSpace(userEmail)) + throw new ArgumentException("User email cannot be null or empty", nameof(userEmail)); + + if (string.IsNullOrWhiteSpace(userFullName)) + throw new ArgumentException("User full name cannot be null or empty", nameof(userFullName)); + + if (Status == ApplicationStatus.Deleted) + throw new InvalidOperationException("Application has already been deleted"); + + Status = ApplicationStatus.Deleted; + DeletedOn = deletedOn; + DeletedBy = deletedBy; + LastModifiedOn = deletedOn; + LastModifiedBy = deletedBy; + + // Raise domain event + AddDomainEvent(new ApplicationDeletedEvent( + Id!, + ApplicationReference, + TemplateVersion!.TemplateId, + deletedBy, + userEmail, + userFullName, + deletedOn)); + } } diff --git a/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs b/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs new file mode 100644 index 00000000..11cb13de --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/Events/ApplicationDeletedEvent.cs @@ -0,0 +1,17 @@ +using GovUK.Dfe.FlexForms.Domain.Common; +using GovUK.Dfe.FlexForms.Domain.ValueObjects; +using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId; + +namespace GovUK.Dfe.FlexForms.Domain.Events; + +public sealed record ApplicationDeletedEvent( + ApplicationId ApplicationId, + string ApplicationReference, + TemplateId TemplateId, + UserId DeletedBy, + string UserEmail, + string UserFullName, + DateTime DeletedOn) : IDomainEvent +{ + public DateTime OccurredOn => DeletedOn; +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs index bcc3b776..d9ea9e5d 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Database/ExternalApplicationsContext.cs @@ -587,6 +587,13 @@ private static void ConfigureApplication(EntityTypeBuilder v!.Value, v => new UserId(v)) .IsRequired(false); + b.Property(e => e.DeletedOn) + .HasColumnName("DeletedOn") + .IsRequired(false); + b.Property(e => e.DeletedBy) + .HasColumnName("DeletedBy") + .HasConversion(v => v!.Value, v => new UserId(v)) + .IsRequired(false); b.HasOne(e => e.TemplateVersion) .WithMany() @@ -599,6 +606,10 @@ private static void ConfigureApplication(EntityTypeBuilder e.LastModifiedBy) .OnDelete(DeleteBehavior.Restrict); + b.HasOne(e => e.DeletedByUser) + .WithMany() + .HasForeignKey(e => e.DeletedBy) + .OnDelete(DeleteBehavior.Restrict); // Index for efficient lookup by ApplicationReference (used by GET /Applications/reference/{applicationReference}) b.HasIndex(e => e.ApplicationReference) diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs new file mode 100644 index 00000000..1874f65a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.Designer.cs @@ -0,0 +1,1174 @@ +// +using System; +using GovUK.Dfe.FlexForms.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations +{ + [DbContext(typeof(ExternalApplicationsContext))] + [Migration("20260814170240_AddApplicationDeletionColumns")] + partial class AddApplicationDeletionColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("ApplicationReference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("ApplicationReference"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeletedBy"); + + b.Property("DeletedOn") + .HasColumnType("datetime2") + .HasColumnName("DeletedOn"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("Status") + .HasColumnType("int") + .HasColumnName("Status"); + + b.Property("TemplateVersionId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateVersionId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationReference") + .IsUnique() + .HasDatabaseName("IX_Applications_ApplicationReference"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn") + .HasDatabaseName("IX_Applications_CreatedOn"); + + b.HasIndex("DeletedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("TemplateVersionId") + .HasDatabaseName("IX_Applications_TemplateVersionId"); + + b.HasIndex("Status", "LastModifiedOn") + .HasDatabaseName("IX_Applications_Status_LastModifiedOn"); + + b.ToTable("Applications", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Applications", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("ResponseId"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ResponseBody"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("ApplicationId", "CreatedOn") + .IsDescending(false, true) + .HasDatabaseName("IX_ApplicationResponses_ApplicationId_CreatedOn"); + + b.ToTable("ApplicationResponses", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("CustomApplicationStatusId"); + + b.Property("ApplicationStatus") + .HasColumnType("int") + .HasColumnName("ApplicationStatus"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("Label"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TemplateId", "ApplicationStatus") + .IsUnique() + .HasDatabaseName("IX_CustomApplicationStatuses_TemplateId_ApplicationStatus"); + + b.ToTable("CustomApplicationStatuses", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_CustomApplicationStatuses", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("FileId"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("Description"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("FileName"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("FileSize"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Name"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("OriginalFileName"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Path"); + + b.Property("UploadedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("UploadedBy"); + + b.Property("UploadedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("UploadedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ValidatedOn") + .HasColumnType("datetime2") + .HasColumnName("ValidatedOn"); + + b.Property("ValidationMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasColumnName("ValidationMessage"); + + b.Property("ValidationSource") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ValidationSource"); + + b.Property("ValidationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)") + .HasColumnName("ValidationStatus"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("IX_Files_ApplicationId"); + + b.HasIndex("UploadedBy"); + + b.HasIndex("ApplicationId", "FileName") + .HasDatabaseName("IX_Files_ApplicationId_FileName"); + + b.HasIndex("Path", "FileName") + .HasDatabaseName("IX_Files_Path_FileName"); + + b.ToTable("Files", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("PermissionId"); + + b.Property("AccessType") + .HasColumnType("tinyint") + .HasColumnName("AccessType"); + + b.Property("ApplicationId") + .HasColumnType("uniqueidentifier") + .HasColumnName("ApplicationId"); + + b.Property("GrantedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("GrantedBy"); + + b.Property("GrantedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("GrantedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ResourceKey"); + + b.Property("ResourceType") + .HasColumnType("tinyint") + .HasColumnName("ResourceType"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.HasKey("Id"); + + b.HasIndex("GrantedBy"); + + b.HasIndex("ApplicationId", "ResourceType") + .HasDatabaseName("IX_Permissions_ApplicationId_ResourceType"); + + b.HasIndex("UserId", "ResourceType", "ApplicationId") + .HasDatabaseName("IX_Permissions_UserId_ResourceType_ApplicationId"); + + b.ToTable("Permissions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.Property("IsSystem") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsSystem"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .HasDatabaseName("IX_Roles_TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique() + .HasDatabaseName("IX_Roles_TenantId_Name") + .HasFilter("[TenantId] IS NOT NULL"); + + b.ToTable("Roles", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Roles", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("RolePermissionId"); + + b.Property("AccessType") + .HasColumnType("int") + .HasColumnName("AccessType"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("ResourceKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("ResourceKey"); + + b.Property("ResourceType") + .HasColumnType("int") + .HasColumnName("ResourceType"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "ResourceType", "ResourceKey", "AccessType") + .IsUnique() + .HasDatabaseName("IX_RolePermissions_Role_Resource_Access"); + + b.ToTable("RolePermissions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TaskAssignmentLabel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("TaskAssignmentLabelsId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("TaskId") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("TaskId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Value"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UserId"); + + b.ToTable("TaskAssignmentLabels", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("IsLive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsLive"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("TenantId"); + + b.ToTable("Templates", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Templates", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplatePermission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplatePermissionId"); + + b.Property("AccessType") + .HasColumnType("tinyint") + .HasColumnName("AccessType"); + + b.Property("GrantedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("GrantedBy"); + + b.Property("GrantedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("GrantedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.HasKey("Id"); + + b.HasIndex("GrantedBy"); + + b.HasIndex("TemplateId"); + + b.HasIndex("UserId", "TemplateId") + .HasDatabaseName("IX_TemplatePermissions_UserId_TemplateId"); + + b.ToTable("TemplatePermissions", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_TemplatePermissions", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateVersionId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("JsonSchema") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("JsonSchema"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TemplateId"); + + b.Property("VersionNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("VersionNumber"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("TemplateId", "CreatedOn") + .IsDescending(false, true) + .HasDatabaseName("IX_TemplateVersions_TemplateId_CreatedOn"); + + b.ToTable("TemplateVersions", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantAccessAudit", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ActorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Details") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("RoleName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SubjectEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("SubjectUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OccurredAtUtc") + .HasDatabaseName("IX_TenantAccessAudits_TenantId_OccurredAtUtc"); + + b.HasIndex("TenantId", "SubjectEmail") + .HasDatabaseName("IX_TenantAccessAudits_TenantId_SubjectEmail"); + + b.ToTable("TenantAccessAudits", "ea"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantMembershipId"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true) + .HasColumnName("IsActive"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TenantMemberships_UserId"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("IX_TenantMemberships_TenantId_UserId"); + + b.ToTable("TenantMemberships", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_TenantMemberships", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatedBy"); + + b.Property("CreatedOn") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasColumnName("CreatedOn") + .HasDefaultValueSql("GETDATE()"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("Email"); + + b.Property("ExternalProviderId") + .HasMaxLength(100) + .IsUnicode(false) + .HasColumnType("varchar(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifiedBy"); + + b.Property("LastModifiedOn") + .HasColumnType("datetime2") + .HasColumnName("LastModifiedOn"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("Name"); + + b.Property("PeriodEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodEnd"); + + b.Property("PeriodStart") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2") + .HasColumnName("PeriodStart"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("RoleId"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ExternalProviderId") + .IsUnique() + .HasFilter("[ExternalProviderId] IS NOT NULL"); + + b.HasIndex("LastModifiedBy"); + + b.HasIndex("RoleId"); + + b.ToTable("Users", "ea"); + + b.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("History_Users", "ea"); + ttb + .HasPeriodStart("PeriodStart") + .HasColumnName("PeriodStart"); + ttb + .HasPeriodEnd("PeriodEnd") + .HasColumnName("PeriodEnd"); + })); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "DeletedByUser") + .WithMany() + .HasForeignKey("DeletedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", "TemplateVersion") + .WithMany() + .HasForeignKey("TemplateVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("DeletedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("TemplateVersion"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.ApplicationResponse", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany("Responses") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Application"); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.CustomApplicationStatus", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.File", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany("Files") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "UploadedByUser") + .WithMany("Files") + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("UploadedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Permission", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId"); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "GrantedByUser") + .WithMany() + .HasForeignKey("GrantedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User") + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("GrantedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.RolePermission", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany("Permissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TaskAssignmentLabel", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "AssignedUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedUser"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplatePermission", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "GrantedByUser") + .WithMany() + .HasForeignKey("GrantedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User") + .WithMany("TemplatePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrantedByUser"); + + b.Navigation("Template"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TemplateVersion", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Template", "Template") + .WithMany("TemplateVersions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.TenantMembership", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("LastModifiedByUser"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Application", b => + { + b.Navigation("Files"); + + b.Navigation("Responses"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.Template", b => + { + b.Navigation("TemplateVersions"); + }); + + modelBuilder.Entity("GovUK.Dfe.FlexForms.Domain.Entities.User", b => + { + b.Navigation("Files"); + + b.Navigation("Permissions"); + + b.Navigation("TemplatePermissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs new file mode 100644 index 00000000..adba9fd1 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/20260814170240_AddApplicationDeletionColumns.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GovUK.Dfe.FlexForms.Infrastructure.Migrations +{ + /// + public partial class AddApplicationDeletionColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedBy", + schema: "ea", + table: "Applications", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletedOn", + schema: "ea", + table: "Applications", + type: "datetime2", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Applications_DeletedBy", + schema: "ea", + table: "Applications", + column: "DeletedBy"); + + migrationBuilder.AddForeignKey( + name: "FK_Applications_Users_DeletedBy", + schema: "ea", + table: "Applications", + column: "DeletedBy", + principalSchema: "ea", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Applications_Users_DeletedBy", + schema: "ea", + table: "Applications"); + + migrationBuilder.DropIndex( + name: "IX_Applications_DeletedBy", + schema: "ea", + table: "Applications"); + + migrationBuilder.DropColumn( + name: "DeletedBy", + schema: "ea", + table: "Applications"); + + migrationBuilder.DropColumn( + name: "DeletedOn", + schema: "ea", + table: "Applications"); + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs index 61076109..8dcf24eb 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Migrations/ExternalApplicationsContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -45,6 +45,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("CreatedOn") .HasDefaultValueSql("GETDATE()"); + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeletedBy"); + + b.Property("DeletedOn") + .HasColumnType("datetime2") + .HasColumnName("DeletedOn"); + b.Property("LastModifiedBy") .HasColumnType("uniqueidentifier") .HasColumnName("LastModifiedBy"); @@ -82,6 +90,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CreatedOn") .HasDatabaseName("IX_Applications_CreatedOn"); + b.HasIndex("DeletedBy"); + b.HasIndex("LastModifiedBy"); b.HasIndex("TemplateVersionId") @@ -884,6 +894,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "DeletedByUser") + .WithMany() + .HasForeignKey("DeletedBy") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("GovUK.Dfe.FlexForms.Domain.Entities.User", "LastModifiedByUser") .WithMany() .HasForeignKey("LastModifiedBy") @@ -897,6 +912,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CreatedByUser"); + b.Navigation("DeletedByUser"); + b.Navigation("LastModifiedByUser"); b.Navigation("TemplateVersion"); diff --git a/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs index 582d3233..d0400f9f 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Api.Tests.Integration/Controllers/ApplicationsControllerTests.cs @@ -186,7 +186,7 @@ public async Task AddApplicationResponseAsync_ShouldReturnBadRequest_WhenBodyIsN { ResponseBody = "this is not base64" }; - + // Act var ex = await Assert.ThrowsAsync>( () => applicationsClient.AddApplicationResponseAsync(new Guid(EaContextSeeder.ApplicationId), request)); @@ -378,158 +378,253 @@ public async Task GetApplicationByReferenceAsync_ShouldReturnNotFound_WhenApplic new AuthenticationHeaderValue("Bearer", "user-token"); // Act - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.GetApplicationByReferenceAsync("InvalidAppRef")); - Assert.Equal(404, ex.StatusCode); - } - - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldSubmitApplication_WhenValidRequest( + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.GetApplicationByReferenceAsync("InvalidAppRef")); + Assert.Equal(404, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task DeleteApplicationAsync_ShouldDeleteApplication_WhenValidRequest( CustomWebApplicationDbContextFactory factory, IApplicationsClient applicationsClient, HttpClient httpClient) - { - // Arrange - factory.TestClaims = new List + { + // Arrange + factory.TestClaims = new List { new(ClaimTypes.Email, EaContextSeeder.BobEmail), - new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write") + new(ClaimTypes.Role, "Admin") }; - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "user-token"); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); - var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); - // Act - var result = await applicationsClient.SubmitApplicationAsync(applicationId); + // Act + var result = await applicationsClient.DeleteApplicationAsync(applicationId); - // Assert - Assert.NotNull(result); - Assert.Equal(applicationId, result.ApplicationId); - Assert.Equal(ApplicationStatus.Submitted, result.Status); - Assert.NotNull(result.DateSubmitted); - } + // Assert + Assert.NotNull(result); + Assert.Equal(applicationId, result.ApplicationId); + Assert.Equal(ApplicationStatus.Deleted, result.Status); + Assert.NotNull(result.DateDeleted); + } - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing( - CustomWebApplicationDbContextFactory factory, - IApplicationsClient applicationsClient, - HttpClient httpClient) - { - // Arrange - var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); - - // Act - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.SubmitApplicationAsync(applicationId)); - Assert.Equal(403, ex.StatusCode); - } - - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing( + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task DeleteApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.DeleteApplicationAsync(applicationId)); + Assert.Equal(403, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task DeleteApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail) + // No Admin permission for this application + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); + + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.DeleteApplicationAsync(applicationId)); + Assert.Equal(403, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task DeleteApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + var nonExistentApplicationId = Guid.NewGuid(); + + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail), + new(ClaimTypes.Role, "Admin") // Give permission for the specific non-existent app + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); + + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.DeleteApplicationAsync(nonExistentApplicationId)); + Assert.Equal(404, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldSubmitApplication_WhenValidRequest( CustomWebApplicationDbContextFactory factory, IApplicationsClient applicationsClient, HttpClient httpClient) - { - // Arrange - factory.TestClaims = new List + { + // Arrange + factory.TestClaims = new List + { + new(ClaimTypes.Email, EaContextSeeder.BobEmail), + new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write") + }; + + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); + + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + + // Act + var result = await applicationsClient.SubmitApplicationAsync(applicationId); + + // Assert + Assert.NotNull(result); + Assert.Equal(applicationId, result.ApplicationId); + Assert.Equal(ApplicationStatus.Submitted, result.Status); + Assert.NotNull(result.DateSubmitted); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldReturnUnauthorized_WhenTokenMissing( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.SubmitApplicationAsync(applicationId)); + Assert.Equal(403, ex.StatusCode); + } + + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenPermissionMissing( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List { new(ClaimTypes.Email, EaContextSeeder.BobEmail) // No Write permission for this application }; - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "user-token"); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); + + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); - var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.SubmitApplicationAsync(applicationId)); + Assert.Equal(403, ex.StatusCode); + } - // Act - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.SubmitApplicationAsync(applicationId)); - Assert.Equal(403, ex.StatusCode); - } + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + var nonExistentApplicationId = Guid.NewGuid(); - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldReturnNotFound_WhenApplicationNotExists( - CustomWebApplicationDbContextFactory factory, - IApplicationsClient applicationsClient, - HttpClient httpClient) - { - // Arrange - var nonExistentApplicationId = Guid.NewGuid(); - - factory.TestClaims = new List + factory.TestClaims = new List { new(ClaimTypes.Email, EaContextSeeder.BobEmail), new("permission", $"Application:{nonExistentApplicationId}:Write") // Give permission for the specific non-existent app }; - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "user-token"); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); - // Act - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.SubmitApplicationAsync(nonExistentApplicationId)); - Assert.Equal(404, ex.StatusCode); - } + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.SubmitApplicationAsync(nonExistentApplicationId)); + Assert.Equal(404, ex.StatusCode); + } - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldReturnBadRequest_WhenApplicationAlreadySubmitted( - CustomWebApplicationDbContextFactory factory, - IApplicationsClient applicationsClient, - HttpClient httpClient) - { - // Arrange - factory.TestClaims = new List + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldReturnBadRequest_WhenApplicationAlreadySubmitted( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange + factory.TestClaims = new List { new(ClaimTypes.Email, EaContextSeeder.BobEmail), new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write") }; - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "user-token"); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); - var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); - // First submission - await applicationsClient.SubmitApplicationAsync(applicationId); + // First submission + await applicationsClient.SubmitApplicationAsync(applicationId); - // Act - Try to submit again - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.SubmitApplicationAsync(applicationId)); - Assert.Equal(400, ex.StatusCode); - } + // Act - Try to submit again + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.SubmitApplicationAsync(applicationId)); + Assert.Equal(400, ex.StatusCode); + } - [Theory] - [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] - public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenUserIsNotApplicationCreator( - CustomWebApplicationDbContextFactory factory, - IApplicationsClient applicationsClient, - HttpClient httpClient) - { - // Arrange - Use Alice's email (Alice exists but didn't create the application - Bob did) - factory.TestClaims = new List + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task SubmitApplicationAsync_ShouldReturnForbidden_WhenUserIsNotApplicationCreator( + CustomWebApplicationDbContextFactory factory, + IApplicationsClient applicationsClient, + HttpClient httpClient) + { + // Arrange - Use Alice's email (Alice exists but didn't create the application - Bob did) + factory.TestClaims = new List { new(ClaimTypes.Email, "alice@example.com"), // Alice exists but didn't create the application new("permission", $"Application:{EaContextSeeder.ApplicationId}:Write") }; - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", "user-token"); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "user-token"); - var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); + var applicationId = Guid.Parse(EaContextSeeder.ApplicationId); - // Act - var ex = await Assert.ThrowsAsync>( - () => applicationsClient.SubmitApplicationAsync(applicationId)); - Assert.Equal(403, ex.StatusCode); - } + // Act + var ex = await Assert.ThrowsAsync>( + () => applicationsClient.SubmitApplicationAsync(applicationId)); + Assert.Equal(403, ex.StatusCode); + } [Theory] [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] @@ -645,7 +740,7 @@ public async Task GetApplicationsForUserAsync_ShouldReturnApplicationsWithSchema // Assert Assert.NotNull(result); Assert.NotEmpty(result.Items); - Assert.All(result.Items, app => + Assert.All(result.Items, app => { Assert.NotNull(app.TemplateSchema); Assert.NotEqual(Guid.Empty, app.TemplateSchema.TemplateId); @@ -678,11 +773,11 @@ public async Task GetApplicationsForUserAsync_ShouldReturnApplicationsWithoutSch // Assert Assert.NotNull(result); Assert.NotEmpty(result.Items); - Assert.All(result.Items, app => + Assert.All(result.Items, app => { Assert.Null(app.TemplateSchema); }); - } + } [Theory] [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] @@ -985,11 +1080,11 @@ public async Task RemoveContributorAsync_ShouldRemoveContributor_WhenValidReques // The endpoint should return 200 OK if successful // We can also verify the contributor was removed by trying to get contributors var contributors = await applicationsClient.GetContributorsAsync(new Guid(EaContextSeeder.ApplicationId)); - + // Check if our specific contributor was removed var removedContributor = contributors.FirstOrDefault(c => c.UserId == addedContributor.UserId); Assert.Null(removedContributor); - + // Also verify that other contributors (if any) are still there var otherContributors = contributors.Where(c => c.UserId != addedContributor.UserId).ToList(); Assert.Contains(otherContributors, c => c.Email == "alice@example.com"); @@ -1421,12 +1516,12 @@ public async Task UploadFileAsync_ShouldHandleDifferentFileTypes_WhenFileTypeIsV // Act - Create a new stream for each test case to ensure it's readable // Create a copy of the content bytes to ensure the stream can be read multiple times var contentBytes = System.Text.Encoding.UTF8.GetBytes(testCase.Content); - + // Ensure we have a valid filename - use the test case filename as both name and file parameter filename var fileName = testCase.FileName ?? "test-file.pdf"; if (string.IsNullOrWhiteSpace(fileName)) fileName = "test-file.pdf"; - + // Create the stream fresh for each iteration with position at start var stream = new MemoryStream(contentBytes); var fileParameter = new FileParameter(stream, fileName, testCase.ContentType); @@ -1655,17 +1750,17 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldReturnHtmlFile_WhenV Assert.NotNull(response); Assert.True(response.IsSuccessStatusCode); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - + // Verify content type Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); - + // Verify content disposition header var contentDisposition = response.Content.Headers.ContentDisposition; Assert.NotNull(contentDisposition); Assert.Equal("attachment", contentDisposition.DispositionType); Assert.Contains(EaContextSeeder.ApplicationReference, contentDisposition.FileName); Assert.EndsWith(".html", contentDisposition.FileName); - + // Verify content var content = await response.Content.ReadAsStringAsync(); Assert.NotEmpty(content); @@ -1760,10 +1855,10 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldIncludeApplicationRe // Assert Assert.True(response.IsSuccessStatusCode); - + var contentDisposition = response.Content.Headers.ContentDisposition; Assert.NotNull(contentDisposition); - + // Verify filename format: application-{reference}-preview.html var expectedFileName = $"application-{EaContextSeeder.ApplicationReference}-preview.html"; Assert.Contains(expectedFileName, contentDisposition.FileName); @@ -1790,16 +1885,16 @@ public async Task DownloadApplicationPreviewHtmlAsync_ShouldReturnValidHtmlConte // Assert Assert.True(response.IsSuccessStatusCode); - + var htmlContent = await response.Content.ReadAsStringAsync(); - + // Verify it's valid HTML Assert.Contains("", htmlContent); Assert.Contains("", htmlContent); Assert.Contains("", htmlContent); Assert.Contains(" applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + var externalId = "test-app-id"; + var userWithExternalId = new User( + user.Id!, + user.RoleId, + user.Name, + user.Email, + user.CreatedOn, + user.CreatedBy, + user.LastModifiedOn, + user.LastModifiedBy, + externalId); + + var applicationId = new ApplicationId(command.ApplicationId); + var templateVersionId = new TemplateVersionId(Guid.NewGuid()); + var application = new Domain.Entities.Application( + applicationId, + "APP-001", + templateVersionId, + DateTime.UtcNow, + userWithExternalId.Id!, + ApplicationStatus.InProgress); + + var templateVersion = new TemplateVersion( + templateVersionId, + new TemplateId(Guid.NewGuid()), + "1.0.0", + "{}", + DateTime.UtcNow, + userWithExternalId.Id!); + application.GetType().GetProperty("TemplateVersion")?.SetValue(application, templateVersion); + + var applications = new[] { application }.AsQueryable().BuildMockDbSet(); + applicationRepo.Query().Returns(applications); + + permissionCheckerService.HasPermission( + ResourceType.Application, + command.ApplicationId.ToString(), + AccessType.Write) + .Returns(true); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturningUser(userWithExternalId), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.Equal(command.ApplicationId, result.Value.ApplicationId); + Assert.Equal(ApplicationStatus.Deleted, result.Value.Status); + await unitOfWork.Received(1).CommitAsync(Arg.Any()); + } + + [Theory] + [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))] + public async Task Handle_ShouldDeleteApplication_WhenValidRequestWithEmail( + DeleteApplicationCommand command, + User user, + IEaRepository applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + var email = "test@example.com"; + var testUser = new User( + user.Id!, + user.RoleId, + user.Name, + email, + user.CreatedOn, + user.CreatedBy, + user.LastModifiedOn, + user.LastModifiedBy); + + var applicationId = new ApplicationId(command.ApplicationId); + var templateVersionId = new TemplateVersionId(Guid.NewGuid()); + var application = new Domain.Entities.Application( + applicationId, + "APP-001", + templateVersionId, + DateTime.UtcNow, + testUser.Id!, + ApplicationStatus.InProgress); + + var templateVersion = new TemplateVersion( + templateVersionId, + new TemplateId(Guid.NewGuid()), + "1.0.0", + "{}", + DateTime.UtcNow, + testUser.Id!); + application.GetType().GetProperty("TemplateVersion")?.SetValue(application, templateVersion); + + var applications = new[] { application }.AsQueryable().BuildMockDbSet(); + applicationRepo.Query().Returns(applications); + + permissionCheckerService.HasPermission( + ResourceType.Application, + command.ApplicationId.ToString(), + AccessType.Write) + .Returns(true); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturningUser(testUser), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.Equal(ApplicationStatus.Deleted, result.Value.Status); + } + + [Theory] + [CustomAutoData(typeof(ApplicationCustomization))] + public async Task Handle_ShouldReturnUnauthorized_WhenUserNotAuthenticated( + DeleteApplicationCommand command, + IEaRepository applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + permissionCheckerService.HasPermission( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturning(Result.Forbid("Not authenticated")), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Not authenticated", result.Error); + } + + [Theory] + [CustomAutoData(typeof(ApplicationCustomization))] + public async Task Handle_ShouldReturnApplicationNotFound_WhenApplicationDoesNotExist( + DeleteApplicationCommand command, + IEaRepository applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + var user = new User( + new UserId(Guid.NewGuid()), + new RoleId(Guid.NewGuid()), + "Test User", + "test@example.com", + DateTime.UtcNow, + null, + null, + null); + + var applications = Array.Empty().AsQueryable().BuildMockDbSet(); + applicationRepo.Query().Returns(applications); + + permissionCheckerService.HasPermission( + ResourceType.Application, + command.ApplicationId.ToString(), + AccessType.Write) + .Returns(true); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturningUser(user), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Application not found", result.Error); + } + + [Theory] + [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))] + public async Task Handle_ShouldReturnForbidden_WhenUserHasNoPermission( + DeleteApplicationCommand command, + User user, + IEaRepository applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + var email = "test@example.com"; + var testUser = new User( + user.Id!, + user.RoleId, + user.Name, + email, + user.CreatedOn, + user.CreatedBy, + user.LastModifiedOn, + user.LastModifiedBy); + + var applicationId = new ApplicationId(command.ApplicationId); + var application = new Domain.Entities.Application( + applicationId, + "APP-001", + new TemplateVersionId(Guid.NewGuid()), + DateTime.UtcNow, + testUser.Id!, + ApplicationStatus.InProgress); + + var applications = new[] { application }.AsQueryable().BuildMockDbSet(); + applicationRepo.Query().Returns(applications); + + permissionCheckerService.HasPermission( + ResourceType.Application, + command.ApplicationId.ToString(), + AccessType.Write) + .Returns(false); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturningUser(testUser), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User does not have permission to delete this application", result.Error); + } + + [Theory] + [CustomAutoData(typeof(ApplicationCustomization), typeof(UserCustomization))] + public async Task Handle_ShouldReturnError_WhenApplicationAlreadyDeleted( + DeleteApplicationCommand command, + User user, + IEaRepository applicationRepo, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork) + { + var email = "test@example.com"; + var testUser = new User( + user.Id!, + user.RoleId, + user.Name, + email, + user.CreatedOn, + user.CreatedBy, + user.LastModifiedOn, + user.LastModifiedBy); + + var applicationId = new ApplicationId(command.ApplicationId); + var application = new Domain.Entities.Application( + applicationId, + "APP-001", + new TemplateVersionId(Guid.NewGuid()), + DateTime.UtcNow, + testUser.Id!, + ApplicationStatus.Deleted); + + var applications = new[] { application }.AsQueryable().BuildMockDbSet(); + applicationRepo.Query().Returns(applications); + + permissionCheckerService.HasPermission( + ResourceType.Application, + command.ApplicationId.ToString(), + AccessType.Write) + .Returns(true); + + var handler = CreateHandler( + applicationRepo, + AuthenticatedUserServiceTestHelper.MockReturningUser(testUser), + permissionCheckerService, + unitOfWork); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("Application has already been deleted", result.Error!); + } + + private static DeleteApplicationCommandHandler CreateHandler( + IEaRepository applicationRepo, + IAuthenticatedUserService authenticatedUserService, + IPermissionCheckerService permissionCheckerService, + IUnitOfWork unitOfWork, + IEaRepository? fileRepository = null, + IFileValidationModeResolver? modeResolver = null, + IApplicationFileValidationPolicy? policy = null) + { + var files = fileRepository ?? Substitute.For>(); + var emptyFiles = Array.Empty().AsQueryable().BuildMockDbSet(); + files.Query().Returns(emptyFiles); + + var resolver = modeResolver ?? Substitute.For(); + resolver.Resolve(Arg.Any()).Returns(FileValidationMode.Off); + + return new DeleteApplicationCommandHandler( + applicationRepo, + authenticatedUserService, + permissionCheckerService, + Substitute.For(), + unitOfWork); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs new file mode 100644 index 00000000..e6098050 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/CommandValidators/Applications/DeleteApplicationCommandValidatorTests.cs @@ -0,0 +1,41 @@ +using GovUK.Dfe.FlexForms.Application.Applications.Commands; + +namespace GovUK.Dfe.FlexForms.Application.Tests.CommandValidators.Applications; + +public class DeleteApplicationCommandValidatorTests +{ + [Theory] + [InlineData("12345678-1234-1234-1234-123456789abc")] + [InlineData("87654321-4321-4321-4321-ba9876543210")] + public void Validate_ShouldSucceed_WhenApplicationIdValid(string applicationIdString) + { + // Arrange + var applicationId = Guid.Parse(applicationIdString); + var command = new DeleteApplicationCommand(applicationId); + var validator = new DeleteApplicationCommandValidator(); + + // Act + var result = validator.Validate(command); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_ShouldFail_WhenApplicationIdEmpty() + { + // Arrange + var command = new DeleteApplicationCommand(Guid.Empty); + var validator = new DeleteApplicationCommandValidator(); + + // Act + var result = validator.Validate(command); + + // Assert + Assert.False(result.IsValid); + Assert.Single(result.Errors); + Assert.Equal("Application ID is required", result.Errors[0].ErrorMessage); + Assert.Equal("ApplicationId", result.Errors[0].PropertyName); + } +} \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs index 1d112d14..8bd6d29c 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/Helpers/ApplicationListingTestHelper.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using ApplicationId = GovUK.Dfe.FlexForms.Domain.ValueObjects.ApplicationId; +using GovUK.Dfe.FlexForms.Domain.Services; namespace GovUK.Dfe.FlexForms.Application.Tests.Helpers; @@ -114,6 +115,7 @@ internal static IApplicationRepository CreateApplicationRepository() internal static GetApplicationsForUserQueryHandler CreateGetApplicationsForUserQueryHandler( IEaRepository userRepo, + IPermissionCheckerService permissionCheckerService, IEaRepository appRepo, ITenantContextAccessor tenantContextAccessor, IUserAccessibleTemplateService accessibleTemplateService, @@ -125,6 +127,7 @@ internal static GetApplicationsForUserQueryHandler CreateGetApplicationsForUserQ return new GetApplicationsForUserQueryHandler( userRepo, appRepo, + permissionCheckerService, CreateApplicationRepository(), cache, tenantContextAccessor, @@ -168,10 +171,10 @@ internal static GetApplicationsByTemplateQueryHandler CreateGetApplicationsByTem httpContextAccessor, userRepo, appRepo, + permissionCheckerService, CreateApplicationRepository(), cache, tenantContextAccessor, - tenantTemplateResolver, - permissionCheckerService); + tenantTemplateResolver); } } diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs index 5eb82f49..a986ae7f 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/QueryHandlers/Applications/GetApplicationsForUserQueryHandlerTests.cs @@ -9,6 +9,7 @@ using GovUK.Dfe.FlexForms.Application.Tests.Helpers; using GovUK.Dfe.FlexForms.Domain.Entities; using GovUK.Dfe.FlexForms.Domain.Interfaces.Repositories; +using GovUK.Dfe.FlexForms.Domain.Services; using GovUK.Dfe.FlexForms.Domain.Tenancy; using GovUK.Dfe.FlexForms.Domain.ValueObjects; using GovUK.Dfe.FlexForms.Tests.Common.Customizations.Entities; @@ -27,6 +28,7 @@ public async Task Handle_ShouldReturnApplications_WhenUserHasPermissions( PermissionCustomization permCustom, ApplicationCustomization appCustom, [Frozen] IEaRepository userRepo, + [Frozen] IPermissionCheckerService permissionCheckerService, [Frozen] IEaRepository appRepo, [Frozen] ITenantContextAccessor tenantContextAccessor) { @@ -67,6 +69,7 @@ public async Task Handle_ShouldReturnApplications_WhenUserHasPermissions( var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!)); @@ -90,7 +93,8 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema ApplicationCustomization appCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -118,6 +122,7 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!)); @@ -137,7 +142,8 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema ApplicationCustomization appCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -165,6 +171,7 @@ public async Task Handle_ShouldReturnApplicationsWithoutSchema_WhenIncludeSchema var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(template.Id!)); @@ -182,7 +189,8 @@ public async Task Handle_ShouldReturnEmpty_WhenUserNotFound( UserCustomization userCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { var userQ = new List().AsQueryable().BuildMock(); userRepo.Query().Returns(userQ); @@ -190,6 +198,7 @@ public async Task Handle_ShouldReturnEmpty_WhenUserNotFound( var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(new TemplateId(Guid.NewGuid()))); @@ -207,7 +216,8 @@ public async Task Handle_ShouldReturnAllResults_WithDefaultPageMetadata_WhenNoPa ApplicationCustomization appCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -234,6 +244,7 @@ public async Task Handle_ShouldReturnAllResults_WithDefaultPageMetadata_WhenNoPa var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(templateId)); @@ -255,7 +266,8 @@ public async Task Handle_ShouldReturnPagedResults_WhenPageNumberAndPageSizeProvi ApplicationCustomization appCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -282,6 +294,7 @@ public async Task Handle_ShouldReturnPagedResults_WhenPageNumberAndPageSizeProvi var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(templateId)); @@ -304,7 +317,8 @@ public async Task Handle_ShouldReturnFilteredResults_WhenSearchReferenceProvided [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, [Frozen] ICacheService cache, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -334,6 +348,7 @@ public async Task Handle_ShouldReturnFilteredResults_WhenSearchReferenceProvided var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateAccessibleTemplateService(templateId), @@ -353,7 +368,8 @@ public async Task Handle_ShouldReturnFromCache( UserCustomization userCustom, [Frozen] IEaRepository userRepo, [Frozen] IEaRepository appRepo, - [Frozen] ITenantContextAccessor tenantContextAccessor) + [Frozen] ITenantContextAccessor tenantContextAccessor, + [Frozen] IPermissionCheckerService permissionCheckerService) { userCustom.OverrideEmail = rawEmail; userCustom.OverridePermissions = Array.Empty(); @@ -368,6 +384,7 @@ public async Task Handle_ShouldReturnFromCache( var handler = ApplicationListingTestHelper.CreateGetApplicationsForUserQueryHandler( userRepo, + permissionCheckerService, appRepo, tenantContextAccessor, ApplicationListingTestHelper.CreateEmptyAccessibleTemplateService()); diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Services/UserTemplateAccessTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Services/UserTemplateAccessTests.cs index e4ce1454..f36680e1 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Services/UserTemplateAccessTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/Services/UserTemplateAccessTests.cs @@ -91,11 +91,9 @@ public void HasWrite_ShouldTreatAnyKeyAsWrite() Assert.False(UserTemplateAccess.IsApplicationInviteOnly(user, new HashSet { templateId.Value })); } - private static User CreateUser(params Permission[] permissions) - { - var userId = new UserId(Guid.NewGuid()); - return new User( - userId, + private static User CreateUser(params Permission[] permissions) => + new User( + new UserId(Guid.NewGuid()), new RoleId(Guid.NewGuid()), "Existing User", "existing@example.com", @@ -104,7 +102,7 @@ private static User CreateUser(params Permission[] permissions) null, null, initialPermissions: permissions); - } + private static Permission TemplateGrant(Guid templateId, AccessType accessType) => new( new PermissionId(Guid.NewGuid()),