From 49c490b477f410a4f2eadd6a7ed586d9bc63af98 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 17 Aug 2026 15:13:07 +0100 Subject: [PATCH 01/10] Phase 1 - Guardrails (arch tests, DI dedupe, session key constants, characterization tests) --- .../GovUK.Dfe.FlexForms.Application.csproj | 2 - .../Interfaces/IApplicationResponseService.cs | 19 +- .../Interfaces/IApplicationStateService.cs | 15 +- .../Interfaces/IFormDataManager.cs | 37 +-- .../Interfaces/IFormSessionStore.cs | 16 ++ .../Interfaces/IFormValidationOrchestrator.cs | 45 +--- .../Interfaces/IInfectedFileStore.cs | 12 + .../Interfaces/INavigationHistoryService.cs | 23 +- .../Validation/FormValidationError.cs | 6 + .../Validation/FormValidationResult.cs | 18 ++ .../GovUK.Dfe.FlexForms.Infrastructure.csproj | 5 +- .../Services/ApplicationResponseService.cs | 79 +++--- .../Services/ApplicationStateService.cs | 73 +++--- .../Services/FormDataManager.cs | 59 +---- .../Services/FormNavigationService.cs | 8 +- .../Services/FormValidationOrchestrator.cs | 207 ++++++---------- .../Services/NavigationHistoryService.cs | 41 ++-- .../Stores/HttpFormSessionStore.cs | 22 ++ .../Stores/RedisInfectedFileStore.cs | 49 ++++ .../FormValidationResultExtensions.cs | 21 ++ .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Contributors-Invite.cshtml.cs | 6 +- .../Pages/Applications/Contributors.cshtml.cs | 8 +- .../Pages/Applications/Dashboard.cshtml.cs | 2 +- .../Pages/FormEngine/BaseFormEngineModel.cs | 5 +- .../FormEngine/RemoveFieldItem.cshtml.cs | 4 +- .../Pages/FormEngine/RenderForm.cshtml.cs | 109 ++++----- .../Pages/FormEngine/UploadFile.cshtml.cs | 6 +- .../Pages/Shared/BaseFormPageModel.cs | 10 +- .../Shared/Fields/_UploadComplexField.cshtml | 19 +- ....FlexForms.Infrastructure.UnitTests.csproj | 5 + .../Services/ApplicationStateServiceTests.cs | 76 +++--- .../FormValidationOrchestratorTests.cs | 67 +++--- .../Stores/HttpFormSessionStoreTests.cs | 52 ++++ .../Stores/RedisInfectedFileStoreTests.cs | 51 ++++ .../Pages/FormEngine/RenderFormModelTests.cs | 226 ++++++++++++------ 36 files changed, 748 insertions(+), 657 deletions(-) create mode 100644 src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs create mode 100644 src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs diff --git a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj index fbca1d2..9034521 100644 --- a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj +++ b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj @@ -8,8 +8,6 @@ - - diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs index 369db6b..346b897 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationResponseService.cs @@ -1,18 +1,17 @@ using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Http; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Application.Interfaces; public interface IApplicationResponseService { - Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, ISession session, CancellationToken cancellationToken = default); + Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, CancellationToken cancellationToken = default); string TransformToResponseJson(Dictionary formData, Dictionary taskStatusData, FormTemplate? template = null); - void AccumulateFormData(Dictionary newData, ISession session); - Dictionary GetAccumulatedFormData(ISession session); - void ClearAccumulatedFormData(ISession session); - Dictionary GetTaskStatusFromSession(Guid applicationId, ISession session); - void SaveTaskStatusToSession(Guid applicationId, string taskId, string status, ISession session); - void StoreFormDataInSession(Dictionary formData, ISession session); - void SetCurrentAccumulatedApplicationId(Guid applicationId, ISession session); -} \ No newline at end of file + void AccumulateFormData(Dictionary newData); + Dictionary GetAccumulatedFormData(); + void ClearAccumulatedFormData(); + Dictionary GetTaskStatusFromSession(Guid applicationId); + void SaveTaskStatusToSession(Guid applicationId, string taskId, string status); + void StoreFormDataInSession(Dictionary formData); + void SetCurrentAccumulatedApplicationId(Guid applicationId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs index 9e24364..442fc82 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IApplicationStateService.cs @@ -1,6 +1,5 @@ using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; -using Microsoft.AspNetCore.Http; using Task = System.Threading.Tasks.Task; namespace GovUK.Dfe.FlexForms.Application.Interfaces @@ -14,17 +13,17 @@ public interface IApplicationStateService /// Loads the application from the API on every call (no session cache for authorization). /// /// When the application does not exist or the user cannot access it. - Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync(string referenceNumber, ISession session); + Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync(string referenceNumber); /// /// Loads response data from API into session /// - Task LoadResponseDataIntoSessionAsync(ApplicationDto application, ISession session); + Task LoadResponseDataIntoSessionAsync(ApplicationDto application); /// /// Gets application status from session or default /// - string GetApplicationStatus(Guid? applicationId, ISession session); + string GetApplicationStatus(Guid? applicationId); /// /// Checks if application is editable based on status @@ -34,17 +33,17 @@ public interface IApplicationStateService /// /// Calculates task status based on form data and explicit status /// - Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus); + Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus); /// /// Saves task status to session and API /// - Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status, ISession session); + Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status); /// /// Checks if all tasks in the template are completed /// - bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus); + bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus); /// /// Validates all required fields across all tasks for submission. @@ -62,4 +61,4 @@ public interface IApplicationStateService /// object GetJsonElementValue(System.Text.Json.JsonElement element); } -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs index c1e0096..0744895 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormDataManager.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Http; - namespace GovUK.Dfe.FlexForms.Application.Interfaces { /// @@ -10,53 +8,36 @@ public interface IFormDataManager /// /// Gets the data for a specific page /// - /// The page ID - /// The application ID - /// The page data as a dictionary Task> GetPageDataAsync(string pageId, string applicationId); - + /// /// Saves the data for a specific page /// - /// The page ID - /// The application ID - /// The data to save - /// A task representing the asynchronous operation Task SavePageDataAsync(string pageId, string applicationId, Dictionary data); - + /// /// Gets the data for a specific task /// - /// The task ID - /// The application ID - /// The task data as a dictionary Task> GetTaskDataAsync(string taskId, string applicationId); - + /// /// Gets all data for an application /// - /// The application ID - /// The application data as a dictionary Task> GetApplicationDataAsync(string applicationId); - + /// /// Accumulates form data in session storage /// - /// The data to accumulate - /// The HTTP session - void AccumulateFormData(Dictionary data, ISession session); - + void AccumulateFormData(Dictionary data); + /// /// Gets accumulated form data from session storage /// - /// The HTTP session - /// The accumulated data as a dictionary - Dictionary GetAccumulatedFormData(ISession session); - + Dictionary GetAccumulatedFormData(); + /// /// Clears accumulated form data from session storage /// - /// The HTTP session - void ClearAccumulatedFormData(ISession session); + void ClearAccumulatedFormData(); } } diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs new file mode 100644 index 0000000..4dd3a7d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormSessionStore.cs @@ -0,0 +1,16 @@ +namespace GovUK.Dfe.FlexForms.Application.Interfaces; + +/// +/// Application port for request-scoped form session state. +/// Implemented in Infrastructure against HTTP session. +/// +public interface IFormSessionStore +{ + string? GetString(string key); + + void SetString(string key, string value); + + void Remove(string key); + + IReadOnlyCollection Keys { get; } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs index f8db195..240c9c7 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IFormValidationOrchestrator.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; +using GovUK.Dfe.FlexForms.Application.Validation; namespace GovUK.Dfe.FlexForms.Application.Interfaces { @@ -10,51 +10,26 @@ public interface IFormValidationOrchestrator /// /// Validates a single page /// - /// The page to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - bool ValidatePage(Domain.Models.Page page, Dictionary data, ModelStateDictionary modelState, Domain.Models.FormTemplate? template = null); - + FormValidationResult ValidatePage(Domain.Models.Page page, Dictionary data, Domain.Models.FormTemplate? template = null); + /// /// Validates a single task /// - /// The task to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - bool ValidateTask(Domain.Models.Task task, Dictionary data, ModelStateDictionary modelState, Domain.Models.FormTemplate? template = null); - + FormValidationResult ValidateTask(Domain.Models.Task task, Dictionary data, Domain.Models.FormTemplate? template = null); + /// /// Validates the entire application /// - /// The form template - /// The form data - /// The model state to add errors to - /// True if validation passes - bool ValidateApplication(Domain.Models.FormTemplate template, Dictionary data, ModelStateDictionary modelState); - + FormValidationResult ValidateApplication(Domain.Models.FormTemplate template, Dictionary data); + /// /// Validates a single field /// - /// The field to validate - /// The field value - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - bool ValidateField(Domain.Models.Field field, object value, ModelStateDictionary modelState, string fieldKey); - + FormValidationResult ValidateField(Domain.Models.Field field, object value, string fieldKey); + /// /// Validates a single field with full form data context for conditional validation /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - bool ValidateField(Domain.Models.Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey); + FormValidationResult ValidateField(Domain.Models.Field field, object value, Dictionary? formData, string fieldKey); } } diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs new file mode 100644 index 0000000..a647022 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/IInfectedFileStore.cs @@ -0,0 +1,12 @@ +namespace GovUK.Dfe.FlexForms.Application.Interfaces; + +/// +/// Application port for the malware-scan blacklist. +/// Implemented in Infrastructure against Redis. +/// +public interface IInfectedFileStore +{ + bool IsFileInfected(Guid fileId); + + bool IsFileNameInfected(string applicationId, string originalFileName); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs b/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs index 0347838..c338bc0 100644 --- a/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs +++ b/src/GovUK.Dfe.FlexForms.Application/Interfaces/INavigationHistoryService.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Http; - namespace GovUK.Dfe.FlexForms.Application.Interfaces { /// @@ -11,34 +9,21 @@ public interface INavigationHistoryService /// /// Pushes a URL onto the navigation history stack for the given scope. /// - /// A unique key identifying the navigation scope (e.g. reference:task[:flow:instance]). - /// The URL to push. - /// The HTTP session to store history in. - void Push(string scopeKey, string url, ISession session); + void Push(string scopeKey, string url); /// /// Returns, without removing, the most recent URL for the scope, or null if none. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to read from. - /// The last URL or null. - string? Peek(string scopeKey, ISession session); + string? Peek(string scopeKey); /// /// Pops and returns the most recent URL for the scope, or null if none. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to read/write. - /// The popped URL or null. - string? Pop(string scopeKey, ISession session); + string? Pop(string scopeKey); /// /// Clears the navigation history for the scope. /// - /// A unique key identifying the navigation scope. - /// The HTTP session to clear from. - void Clear(string scopeKey, ISession session); + void Clear(string scopeKey); } } - - diff --git a/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs new file mode 100644 index 0000000..2206a41 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationError.cs @@ -0,0 +1,6 @@ +namespace GovUK.Dfe.FlexForms.Application.Validation; + +/// +/// A single field validation failure. Presentation maps this to ModelState. +/// +public sealed record FormValidationError(string FieldKey, string Message); diff --git a/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs new file mode 100644 index 0000000..1c0924d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/Validation/FormValidationResult.cs @@ -0,0 +1,18 @@ +namespace GovUK.Dfe.FlexForms.Application.Validation; + +/// +/// Outcome of form-engine validation without ASP.NET ModelState. +/// +public sealed class FormValidationResult +{ + public static FormValidationResult Success { get; } = new([]); + + public FormValidationResult(IReadOnlyList errors) + { + Errors = errors; + } + + public IReadOnlyList Errors { get; } + + public bool IsValid => Errors.Count == 0; +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj index aed020a..7312cf9 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/GovUK.Dfe.FlexForms.Infrastructure.csproj @@ -6,13 +6,16 @@ enable + + + + - diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs index f1fbfe8..7e1716e 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs @@ -1,11 +1,8 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using StackExchange.Redis; using System.Text.Json; using Task = System.Threading.Tasks.Task; @@ -13,26 +10,27 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Services; public class ApplicationResponseService( IApplicationsClient applicationsClient, - IConnectionMultiplexer redis, + IInfectedFileStore infectedFileStore, + IFormSessionStore sessionStore, IFormTemplateProvider formTemplateProvider, ILogger logger) : IApplicationResponseService { private const string SessionKeyFormData = "AccumulatedFormData"; - public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, ISession session, CancellationToken cancellationToken = default) + public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, CancellationToken cancellationToken = default) { try { // Accumulate the new data with existing data (infected files filtered by blacklist) - AccumulateFormData(formData, session); + AccumulateFormData(formData); // Get all accumulated data - var allFormData = GetAccumulatedFormData(session); + var allFormData = GetAccumulatedFormData(); - var taskStatusData = GetTaskStatusFromSession(applicationId, session); + var taskStatusData = GetTaskStatusFromSession(applicationId); - var template = await TryGetTemplateFromSessionAsync(session, cancellationToken); + var template = await TryGetTemplateFromSessionAsync(cancellationToken); var responseJson = TransformToResponseJson(allFormData, taskStatusData, template); @@ -43,7 +41,7 @@ public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary allFormData, ISession session, CancellationToken cancellationToken) + private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dictionary allFormData, CancellationToken cancellationToken) { try { @@ -70,7 +68,7 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti { // Get current application status from session var statusKey = $"ApplicationStatus_{applicationId}"; - var currentStatus = session.GetString(statusKey); + var currentStatus = sessionStore.GetString(statusKey); // Promote Created/empty session status to InProgress once data is saved. // Do not overwrite Submitted (or other terminal statuses). @@ -78,7 +76,7 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti || currentStatus.Equals("Created", StringComparison.OrdinalIgnoreCase) || currentStatus.Equals("InProgress", StringComparison.OrdinalIgnoreCase)) { - session.SetString(statusKey, "InProgress"); + sessionStore.SetString(statusKey, "InProgress"); logger.LogInformation("Updated application {ApplicationId} status to InProgress due to form data being saved", applicationId); } } @@ -90,10 +88,10 @@ private async Task EnsureApplicationStatusIsInProgress(Guid applicationId, Dicti } } - public void AccumulateFormData(Dictionary newData, ISession session) + public void AccumulateFormData(Dictionary newData) { // Get existing data (infected files will be filtered by blacklist) - var existingData = GetAccumulatedFormData(session); + var existingData = GetAccumulatedFormData(); foreach (var kvp in newData) { @@ -116,7 +114,7 @@ public void AccumulateFormData(Dictionary newData, ISession sess } var jsonString = JsonSerializer.Serialize(existingData); - session.SetString(SessionKeyFormData, jsonString); + sessionStore.SetString(SessionKeyFormData, jsonString); } private bool AreEquivalentFieldNames(string fieldName1, string fieldName2) @@ -143,9 +141,9 @@ private string NormalizeFieldName(string fieldName) /// /// Gets accumulated form data with infected file filtering /// - public Dictionary GetAccumulatedFormData(ISession session) + public Dictionary GetAccumulatedFormData() { - var jsonString = session.GetString(SessionKeyFormData); + var jsonString = sessionStore.GetString(SessionKeyFormData); if (string.IsNullOrEmpty(jsonString)) { @@ -158,7 +156,7 @@ public Dictionary GetAccumulatedFormData(ISession session) ?? new Dictionary(); // Get applicationId from session for filename-based blacklist checking - var applicationId = session.GetString("ApplicationId"); + var applicationId = sessionStore.GetString("ApplicationId"); // Filter out any infected files from the data var filteredData = FilterInfectedFilesFromData(rawData, applicationId); @@ -220,9 +218,9 @@ private object CleanFormValue(object value) return value.ToString() ?? string.Empty; } - public void ClearAccumulatedFormData(ISession session) + public void ClearAccumulatedFormData() { - session.Remove(SessionKeyFormData); + sessionStore.Remove(SessionKeyFormData); logger.LogInformation("Cleared accumulated form data from session"); } @@ -235,7 +233,6 @@ private Dictionary FilterInfectedFilesFromData(Dictionary FilterInfectedFilesFromData(Dictionary FilterInfectedFilesFromData(Dictionary TryGetTemplateFromSessionAsync(ISession session, CancellationToken cancellationToken) + private async Task TryGetTemplateFromSessionAsync(CancellationToken cancellationToken) { try { - var templateId = session.GetString("TemplateId"); + var templateId = sessionStore.GetString("TemplateId"); if (string.IsNullOrWhiteSpace(templateId)) { logger.LogWarning("No TemplateId in session when saving application response; question/dataType will use runtime fallbacks only"); @@ -397,16 +388,16 @@ public string TransformToResponseJson( } } - public Dictionary GetTaskStatusFromSession(Guid applicationId, ISession session) + public Dictionary GetTaskStatusFromSession(Guid applicationId) { var taskStatusData = new Dictionary(); - var sessionKeys = session.Keys.Where(k => k.StartsWith($"TaskStatus_{applicationId}_")).ToList(); + var sessionKeys = sessionStore.Keys.Where(k => k.StartsWith($"TaskStatus_{applicationId}_")).ToList(); foreach (var sessionKey in sessionKeys) { var taskId = sessionKey.Substring($"TaskStatus_{applicationId}_".Length); - var statusValue = session.GetString(sessionKey); + var statusValue = sessionStore.GetString(sessionKey); if (!string.IsNullOrEmpty(statusValue)) { @@ -417,22 +408,22 @@ public Dictionary GetTaskStatusFromSession(Guid applicationId, I return taskStatusData; } - public void SaveTaskStatusToSession(Guid applicationId, string taskId, string status, ISession session) + public void SaveTaskStatusToSession(Guid applicationId, string taskId, string status) { var sessionKey = $"TaskStatus_{applicationId}_{taskId}"; - session.SetString(sessionKey, status); + sessionStore.SetString(sessionKey, status); } - public void StoreFormDataInSession(Dictionary formData, ISession session) + public void StoreFormDataInSession(Dictionary formData) { // Clear existing data and store new data - ClearAccumulatedFormData(session); - AccumulateFormData(formData, session); + ClearAccumulatedFormData(); + AccumulateFormData(formData); } - public void SetCurrentAccumulatedApplicationId(Guid applicationId, ISession session) + public void SetCurrentAccumulatedApplicationId(Guid applicationId) { - session.SetString("CurrentAccumulatedApplicationId", applicationId.ToString()); + sessionStore.SetString("CurrentAccumulatedApplicationId", applicationId.ToString()); } } \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs index 6ff860f..779d09a 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs @@ -4,7 +4,6 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System.Text.Json; using Task = System.Threading.Tasks.Task; @@ -18,17 +17,17 @@ public class ApplicationStateService( IApplicationsClient applicationsClient, IApplicationResponseService applicationResponseService, IFieldRequirementService fieldRequirementService, + IFormSessionStore sessionStore, ILogger logger) : IApplicationStateService { public async Task<(Guid? ApplicationId, ApplicationDto? Application)> EnsureApplicationIdAsync( - string referenceNumber, - ISession session) + string referenceNumber) { if (string.IsNullOrWhiteSpace(referenceNumber)) throw new ApplicationAccessException(referenceNumber ?? string.Empty); - ClearStaleSessionDataIfReferenceChanged(referenceNumber, session); + ClearStaleSessionDataIfReferenceChanged(referenceNumber); ApplicationDto application; try @@ -58,8 +57,8 @@ public class ApplicationStateService( throw new ApplicationAccessException(referenceNumber); } - PersistApplicationToSession(application, referenceNumber, session); - await LoadResponseDataIntoSessionAsync(application, session); + PersistApplicationToSession(application, referenceNumber); + await LoadResponseDataIntoSessionAsync(application); logger.LogDebug( "Loaded application {ApplicationId} from API for reference {ReferenceNumber}", @@ -69,13 +68,13 @@ public class ApplicationStateService( return (application.ApplicationId, application); } - public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, ISession session) + public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application) { if (application.LatestResponse?.ResponseBody == null) { logger.LogInformation("No existing response data found for application {ApplicationReference}", application.ApplicationReference); - applicationResponseService.ClearAccumulatedFormData(session); - applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId, session); + applicationResponseService.ClearAccumulatedFormData(); + applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId); return; } @@ -119,7 +118,7 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I if (!string.IsNullOrEmpty(statusValue)) { - applicationResponseService.SaveTaskStatusToSession(application.ApplicationId, taskId, statusValue, session); + applicationResponseService.SaveTaskStatusToSession(application.ApplicationId, taskId, statusValue); logger.LogDebug("Restored task status: {TaskId} = {Status}", taskId, statusValue); } } @@ -146,8 +145,8 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I } // Store in session using the same key structure as form submission - applicationResponseService.StoreFormDataInSession(formDataDict, session); - applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId, session); + applicationResponseService.StoreFormDataInSession(formDataDict); + applicationResponseService.SetCurrentAccumulatedApplicationId(application.ApplicationId); logger.LogInformation("Successfully loaded {FieldCount} fields from API into session for application {ApplicationReference}", formDataDict.Count, application.ApplicationReference); @@ -159,24 +158,24 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application, I } } - private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber, ISession session) + private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber) { - var sessionReference = session.GetString("ApplicationReference"); + var sessionReference = sessionStore.GetString("ApplicationReference"); if (string.IsNullOrEmpty(sessionReference) || string.Equals(sessionReference, referenceNumber, StringComparison.OrdinalIgnoreCase)) { return; } - applicationResponseService.ClearAccumulatedFormData(session); - session.Remove("ApplicationId"); - session.Remove("ApplicationReference"); + applicationResponseService.ClearAccumulatedFormData(); + sessionStore.Remove("ApplicationId"); + sessionStore.Remove("ApplicationReference"); } - private void PersistApplicationToSession(ApplicationDto application, string referenceNumber, ISession session) + private void PersistApplicationToSession(ApplicationDto application, string referenceNumber) { - session.SetString("ApplicationId", application.ApplicationId.ToString()); - session.SetString("ApplicationReference", application.ApplicationReference ?? referenceNumber); + sessionStore.SetString("ApplicationId", application.ApplicationId.ToString()); + sessionStore.SetString("ApplicationReference", application.ApplicationReference ?? referenceNumber); var templateSchemaKey = $"TemplateSchema_{referenceNumber}"; var templateVersionIdKey = $"TemplateVersionId_{referenceNumber}"; @@ -184,37 +183,37 @@ private void PersistApplicationToSession(ApplicationDto application, string refe if (application.TemplateSchema?.JsonSchema != null) { - session.SetString(templateSchemaKey, application.TemplateSchema.JsonSchema); - session.SetString(templateVersionIdKey, application.TemplateVersionId.ToString()); - session.SetString(templateVersionNoKey, application.TemplateSchema.VersionNumber ?? string.Empty); + sessionStore.SetString(templateSchemaKey, application.TemplateSchema.JsonSchema); + sessionStore.SetString(templateVersionIdKey, application.TemplateVersionId.ToString()); + sessionStore.SetString(templateVersionNoKey, application.TemplateSchema.VersionNumber ?? string.Empty); } if (application.Status != null) { var statusKey = $"ApplicationStatus_{application.ApplicationId}"; - session.SetString(statusKey, application.Status.ToString()); + sessionStore.SetString(statusKey, application.Status.ToString()); } if (application.CreatedBy != null) { - session.SetString($"ApplicationLeadApplicantName_{application.ApplicationId}", application.CreatedBy.Name); - session.SetString($"ApplicationLeadApplicantEmail_{application.ApplicationId}", application.CreatedBy.Email); - session.SetString($"ApplicationLeadApplicantUserId_{application.ApplicationId}", application.CreatedBy.UserId.ToString()); + sessionStore.SetString($"ApplicationLeadApplicantName_{application.ApplicationId}", application.CreatedBy.Name ?? string.Empty); + sessionStore.SetString($"ApplicationLeadApplicantEmail_{application.ApplicationId}", application.CreatedBy.Email ?? string.Empty); + sessionStore.SetString($"ApplicationLeadApplicantUserId_{application.ApplicationId}", application.CreatedBy.UserId.ToString()); } - session.SetString( + sessionStore.SetString( $"ApplicationFormVersion_{application.ApplicationId}", string.IsNullOrEmpty(application.TemplateSchema?.VersionNumber) ? "N/A" : application.TemplateSchema.VersionNumber); } - public string GetApplicationStatus(Guid? applicationId, ISession session) + public string GetApplicationStatus(Guid? applicationId) { if (applicationId.HasValue) { var statusKey = $"ApplicationStatus_{applicationId.Value}"; - return session.GetString(statusKey) ?? "InProgress"; + return sessionStore.GetString(statusKey) ?? "InProgress"; } return "InProgress"; } @@ -227,7 +226,7 @@ public bool IsApplicationEditable(string applicationStatus) || applicationStatus.Equals("Created", StringComparison.OrdinalIgnoreCase); } - public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus) + public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus) { // If application is submitted, all tasks are completed if (applicationStatus.Equals("Submitted", StringComparison.OrdinalIgnoreCase)) @@ -239,7 +238,7 @@ public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate if (applicationId.HasValue) { var sessionKey = $"TaskStatus_{applicationId.Value}_{taskId}"; - var statusString = session.GetString(sessionKey); + var statusString = sessionStore.GetString(sessionKey); if (!string.IsNullOrEmpty(statusString) && Enum.TryParse(statusString, out var explicitStatus) && @@ -309,17 +308,17 @@ public Domain.Models.TaskStatus CalculateTaskStatus(string taskId, FormTemplate return Domain.Models.TaskStatus.NotStarted; } - public async Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status, ISession session) + public async Task SaveTaskStatusAsync(Guid applicationId, string taskId, Domain.Models.TaskStatus status) { // Save task status to session - applicationResponseService.SaveTaskStatusToSession(applicationId, taskId, status.ToString(), session); + applicationResponseService.SaveTaskStatusToSession(applicationId, taskId, status.ToString()); // Save all accumulated data (including task status) to API var formData = new Dictionary(); // Empty form data since we're just updating task status - await applicationResponseService.SaveApplicationResponseAsync(applicationId, formData, session); + await applicationResponseService.SaveApplicationResponseAsync(applicationId, formData); } - public bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, ISession session, string applicationStatus) + public bool AreAllTasksCompleted(FormTemplate template, Dictionary formData, Guid? applicationId, string applicationStatus) { if (template?.TaskGroups == null) { @@ -329,7 +328,7 @@ public bool AreAllTasksCompleted(FormTemplate template, Dictionary g.Tasks).ToList(); return allTasks.All(task => - CalculateTaskStatus(task.TaskId, template, formData, applicationId, session, applicationStatus) == Domain.Models.TaskStatus.Completed); + CalculateTaskStatus(task.TaskId, template, formData, applicationId, applicationStatus) == Domain.Models.TaskStatus.Completed); } public object GetJsonElementValue(JsonElement element) diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs index 9f314ac..da6a5f3 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormDataManager.cs @@ -1,5 +1,4 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace GovUK.Dfe.FlexForms.Infrastructure.Services @@ -20,32 +19,17 @@ public FormDataManager( _logger = logger; } - /// - /// Gets the data for a specific page - /// - /// The page ID - /// The application ID - /// The page data as a dictionary public async Task> GetPageDataAsync(string pageId, string applicationId) { - // This would need to be implemented based on how page-specific data is stored - // For now, we'll return an empty dictionary _logger.LogDebug("Getting page data for page {PageId} and application {ApplicationId}", pageId, applicationId); return new Dictionary(); } - /// - /// Saves the data for a specific page - /// - /// The page ID - /// The application ID - /// The data to save - /// A task representing the asynchronous operation public async Task SavePageDataAsync(string pageId, string applicationId, Dictionary data) { if (Guid.TryParse(applicationId, out var appId)) { - await _applicationResponseService.SaveApplicationResponseAsync(appId, data, null); + await _applicationResponseService.SaveApplicationResponseAsync(appId, data); _logger.LogInformation("Saved page data for page {PageId} and application {ApplicationId}", pageId, applicationId); } else @@ -54,63 +38,34 @@ public async Task SavePageDataAsync(string pageId, string applicationId, Diction } } - /// - /// Gets the data for a specific task - /// - /// The task ID - /// The application ID - /// The task data as a dictionary public async Task> GetTaskDataAsync(string taskId, string applicationId) { - // This would need to be implemented based on how task-specific data is stored - // For now, we'll return an empty dictionary _logger.LogDebug("Getting task data for task {TaskId} and application {ApplicationId}", taskId, applicationId); return new Dictionary(); } - /// - /// Gets all data for an application - /// - /// The application ID - /// The application data as a dictionary public async Task> GetApplicationDataAsync(string applicationId) { - // This would need to be implemented based on how application data is stored - // For now, we'll return an empty dictionary _logger.LogDebug("Getting application data for application {ApplicationId}", applicationId); return new Dictionary(); } - /// - /// Accumulates form data in session storage - /// - /// The data to accumulate - /// The HTTP session - public void AccumulateFormData(Dictionary data, ISession session) + public void AccumulateFormData(Dictionary data) { - _applicationResponseService.AccumulateFormData(data, session); + _applicationResponseService.AccumulateFormData(data); _logger.LogDebug("Accumulated {Count} form data entries in session", data.Count); } - /// - /// Gets accumulated form data from session storage - /// - /// The HTTP session - /// The accumulated data as a dictionary - public Dictionary GetAccumulatedFormData(ISession session) + public Dictionary GetAccumulatedFormData() { - var data = _applicationResponseService.GetAccumulatedFormData(session); + var data = _applicationResponseService.GetAccumulatedFormData(); _logger.LogDebug("Retrieved {Count} accumulated form data entries from session", data.Count); return data; } - /// - /// Clears accumulated form data from session storage - /// - /// The HTTP session - public void ClearAccumulatedFormData(ISession session) + public void ClearAccumulatedFormData() { - _applicationResponseService.ClearAccumulatedFormData(session); + _applicationResponseService.ClearAccumulatedFormData(); _logger.LogDebug("Cleared accumulated form data from session"); } } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs index 140243e..d51b653 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs @@ -1,5 +1,4 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; -using Microsoft.AspNetCore.Http; namespace GovUK.Dfe.FlexForms.Infrastructure.Services { @@ -9,12 +8,10 @@ namespace GovUK.Dfe.FlexForms.Infrastructure.Services public class FormNavigationService : IFormNavigationService { private readonly INavigationHistoryService _history; - private readonly IHttpContextAccessor _httpContextAccessor; - public FormNavigationService(INavigationHistoryService history, IHttpContextAccessor httpContextAccessor) + public FormNavigationService(INavigationHistoryService history) { _history = history; - _httpContextAccessor = httpContextAccessor; } /// /// Gets the URL for the next page in the form @@ -98,10 +95,9 @@ public string GetBackLinkUrl(string currentPageId, string taskId, string referen { // Build scope: reference:task[:flow:instance] var scope = BuildScope(referenceNumber, taskId, currentPageId); - var session = _httpContextAccessor.HttpContext?.Session; // Prefer history when available - var last = session != null ? _history.Peek(scope, session) : null; + var last = _history.Peek(scope); if (!string.IsNullOrEmpty(last)) { // Append nav=back so GET can pop diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs index cdb4565..d3d0357 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs @@ -1,6 +1,6 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; using GovUK.Dfe.FlexForms.Domain.Models; -using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -30,144 +30,79 @@ public FormValidationOrchestrator( _fieldRequirementService = fieldRequirementService; } - /// - /// Validates a single page - /// - /// The page to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - public bool ValidatePage(Page page, Dictionary data, ModelStateDictionary modelState, FormTemplate? template = null) + public FormValidationResult ValidatePage(Page page, Dictionary data, FormTemplate? template = null) { - if (page?.Fields == null) - { - return true; - } - - var isValid = true; - foreach (var field in page.Fields) - { - var key = field.FieldId; - data.TryGetValue(key, out var rawValue); + var errors = new List(); + CollectPageErrors(page, data, errors, template); + return new FormValidationResult(errors); + } + public FormValidationResult ValidateTask(Task task, Dictionary data, FormTemplate? template = null) + { + var errors = new List(); + CollectTaskErrors(task, data, errors, template); + return new FormValidationResult(errors); + } - if (field.Type == "checkboxes") { - if (!ValidateField(field, rawValue ?? string.Empty, data, modelState, key, template)) - { - isValid = false; - } - } - else - { - var value = rawValue?.ToString() ?? string.Empty; - if (!ValidateField(field, value, data, modelState, key, template)) - { - isValid = false; - } - } + public FormValidationResult ValidateApplication(FormTemplate template, Dictionary data) + { + var errors = new List(); + if (template?.TaskGroups == null) + return FormValidationResult.Success; - + foreach (var group in template.TaskGroups) + { + foreach (var task in group.Tasks) + CollectTaskErrors(task, data, errors); } - return isValid; + return new FormValidationResult(errors); } - /// - /// Validates a single task - /// - /// The task to validate - /// The form data - /// The model state to add errors to - /// Optional template for field requirement policy - /// True if validation passes - public bool ValidateTask(Task task, Dictionary data, ModelStateDictionary modelState, FormTemplate? template = null) + public FormValidationResult ValidateField(Field field, object value, string fieldKey) { - if (task?.Pages == null) - { - return true; - } + return ValidateField(field, value, null, fieldKey, null); + } - var isValid = true; - foreach (var page in task.Pages) - { - if (!ValidatePage(page, data, modelState, template)) - { - isValid = false; - } - } + public FormValidationResult ValidateField(Field field, object value, Dictionary? formData, string fieldKey) + { + return ValidateField(field, value, formData, fieldKey, null); + } - return isValid; + public FormValidationResult ValidateField(Field field, object value, Dictionary? formData, string fieldKey, FormTemplate? template) + { + var errors = new List(); + ValidateFieldCore(field, value, formData, errors, fieldKey, template); + return new FormValidationResult(errors); } - /// - /// Validates the entire application - /// - /// The form template - /// The form data - /// The model state to add errors to - /// True if validation passes - public bool ValidateApplication(FormTemplate template, Dictionary data, ModelStateDictionary modelState) + private void CollectPageErrors(Page page, Dictionary data, List errors, FormTemplate? template = null) { - if (template?.TaskGroups == null) - { - return true; - } + if (page?.Fields == null) + return; - var isValid = true; - foreach (var group in template.TaskGroups) + foreach (var field in page.Fields) { - foreach (var task in group.Tasks) - { - if (!ValidateTask(task, data, modelState)) - { - isValid = false; - } - } - } + var key = field.FieldId; + data.TryGetValue(key, out var rawValue); - return isValid; + if (field.Type == "checkboxes") + ValidateFieldCore(field, rawValue ?? string.Empty, data, errors, key, template); + else + ValidateFieldCore(field, rawValue?.ToString() ?? string.Empty, data, errors, key, template); + } } - /// - /// Validates a single field - /// - /// The field to validate - /// The field value - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - public bool ValidateField(Field field, object value, ModelStateDictionary modelState, string fieldKey) + private void CollectTaskErrors(Task task, Dictionary data, List errors, FormTemplate? template = null) { - // Call the overloaded method with null data and template for backward compatibility - return ValidateField(field, value, null, modelState, fieldKey, null); - } + if (task?.Pages == null) + return; - /// - /// Validates a single field with full form data context for conditional validation - /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// True if validation passes - public bool ValidateField(Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey) - { - return ValidateField(field, value, formData, modelState, fieldKey, null); + foreach (var page in task.Pages) + CollectPageErrors(page, data, errors, template); } - /// - /// Validates a single field with full form data context, conditional validation, and template-based requirement policy - /// - /// The field to validate - /// The field value - /// The complete form data for conditional evaluation - /// The model state to add errors to - /// The field key for model state - /// The template containing the default field requirement policy - /// True if validation passes - public bool ValidateField(Field field, object value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey, FormTemplate? template) + private bool ValidateFieldCore(Field field, object value, Dictionary? formData, List errors, string fieldKey, FormTemplate? template) { var normalizedCheckboxValues = field.Type == "checkboxes" ? CheckboxValueNormalizer.Normalize(value) @@ -182,7 +117,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (field.Type == "complexField" && field.ComplexField != null) { // Pass template to complex field validation so it can check global required policy - return ValidateComplexField(field, value, formData, modelState, fieldKey, template); + return ValidateComplexField(field, value, formData, errors, fieldKey, template); } // Check if field is required based on template policy (before explicit validation rules) @@ -199,7 +134,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (string.IsNullOrWhiteSpace(stringValue)) { var fieldLabel = field.Label?.Value ?? field.FieldId; - modelState.AddModelError(fieldKey, $"{fieldLabel} is required"); + errors.Add(new FormValidationError(fieldKey, $"{fieldLabel} is required")); isValid = false; } } @@ -226,13 +161,13 @@ public bool ValidateField(Field field, object value, Dictionary? if (missingParts) { - modelState.AddModelError(fieldKey, $"{validationLabel} must include a day, month and year"); + errors.Add(new FormValidationError(fieldKey, $"{validationLabel} must include a day, month and year")); isValid = false; } else if (!DateTime.TryParseExact(stringValue, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) { // All parts present and numeric but not a real calendar date - modelState.AddModelError(fieldKey, $"{validationLabel} must be a real date"); + errors.Add(new FormValidationError(fieldKey, $"{validationLabel} must be a real date")); isValid = false; } } @@ -246,7 +181,7 @@ public bool ValidateField(Field field, object value, Dictionary? var emailAttr = new EmailAddressAttribute(); if (!emailAttr.IsValid(stringValue)) { - modelState.AddModelError(fieldKey, "Enter an email address in the correct format, for example, name@example.com"); + errors.Add(new FormValidationError(fieldKey, "Enter an email address in the correct format, for example, name@example.com")); isValid = false; } } @@ -263,7 +198,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (!isValidOption) { var message = GetCustomRequiredMessage(field) ?? "Select an option from the list"; - modelState.AddModelError(fieldKey, message); + errors.Add(new FormValidationError(fieldKey, message)); isValid = false; break; } @@ -276,7 +211,7 @@ public bool ValidateField(Field field, object value, Dictionary? if (!isValidOption) { var message = GetCustomRequiredMessage(field) ?? "Select an option from the list"; - modelState.AddModelError(fieldKey, message); + errors.Add(new FormValidationError(fieldKey, message)); isValid = false; } @@ -319,7 +254,7 @@ public bool ValidateField(Field field, object value, Dictionary? case "required": if (string.IsNullOrWhiteSpace(stringValue)) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } break; @@ -330,7 +265,7 @@ public bool ValidateField(Field field, object value, Dictionary? var regexMatch = Regex.IsMatch(stringValue, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(200)); if (!regexMatch) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -343,7 +278,7 @@ public bool ValidateField(Field field, object value, Dictionary? var plainTextForMaxLengthValidation = FormSanitisedTextNormalizer.ToPlainTextForCharacterCountValidation(stringValue); if (plainTextForMaxLengthValidation.Length > maxLength) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -351,7 +286,7 @@ public bool ValidateField(Field field, object value, Dictionary? case "maxWords": if (!ValidateWordCount(stringValue, rule)) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } break; @@ -373,11 +308,11 @@ public bool ValidateField(Field field, object value, Dictionary? /// The complex field to validate /// The field value /// The complete form data for conditional evaluation - /// The model state to add errors to + /// The model state to add errors to /// The field key for model state /// The template containing the default field requirement policy /// True if validation passes - private bool ValidateComplexField(Field field, object? value, Dictionary? formData, ModelStateDictionary modelState, string fieldKey, FormTemplate? template = null) + private bool ValidateComplexField(Field field, object? value, Dictionary? formData, List errors, string fieldKey, FormTemplate? template = null) { var stringValue = value?.ToString() ?? string.Empty; var isValid = true; @@ -400,7 +335,7 @@ private bool ValidateComplexField(Field field, object? value, Dictionary maxLength) { - modelState.AddModelError(fieldKey, rule.Message); + errors.Add(new FormValidationError(fieldKey, rule.Message)); isValid = false; } } @@ -519,7 +454,7 @@ private bool ValidateComplexField(Field field, object? value, Dictionary - public class NavigationHistoryService(ILogger logger) : INavigationHistoryService + public class NavigationHistoryService( + IFormSessionStore sessionStore, + ILogger logger) : INavigationHistoryService { private const string SessionPrefix = "NavHistory_"; private const int MaxDepth = 25; - public void Push(string scopeKey, string url, ISession session) + public void Push(string scopeKey, string url) { if (string.IsNullOrWhiteSpace(scopeKey) || string.IsNullOrWhiteSpace(url)) return; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); // Avoid pushing duplicates of the latest entry if (stack.Count == 0 || !string.Equals(stack[^1], url, StringComparison.OrdinalIgnoreCase)) @@ -26,40 +27,39 @@ public void Push(string scopeKey, string url, ISession session) stack.Add(url); if (stack.Count > MaxDepth) { - // Trim oldest stack.RemoveAt(0); } - Save(session, key, stack); + Save(key, stack); } } - public string? Peek(string scopeKey, ISession session) + public string? Peek(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return null; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); return stack.Count > 0 ? stack[^1] : null; } - public string? Pop(string scopeKey, ISession session) + public string? Pop(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return null; var key = SessionPrefix + scopeKey; - var stack = Load(session, key); + var stack = Load(key); if (stack.Count == 0) return null; var last = stack[^1]; stack.RemoveAt(stack.Count - 1); - Save(session, key, stack); + Save(key, stack); return last; } - public void Clear(string scopeKey, ISession session) + public void Clear(string scopeKey) { if (string.IsNullOrWhiteSpace(scopeKey)) return; var key = SessionPrefix + scopeKey; try { - session.Remove(key); + sessionStore.Remove(key); } catch (Exception ex) { @@ -67,13 +67,12 @@ public void Clear(string scopeKey, ISession session) } } - private static List Load(ISession session, string key) + private List Load(string key) { try { - var bytes = session.Get(key); - if (bytes == null) return new List(); - var json = System.Text.Encoding.UTF8.GetString(bytes); + var json = sessionStore.GetString(key); + if (string.IsNullOrEmpty(json)) return new List(); var list = JsonSerializer.Deserialize>(json); return list ?? new List(); } @@ -83,13 +82,11 @@ private static List Load(ISession session, string key) } } - private static void Save(ISession session, string key, List values) + private void Save(string key, List values) { try { - var json = JsonSerializer.Serialize(values); - var bytes = System.Text.Encoding.UTF8.GetBytes(json); - session.Set(key, bytes); + sessionStore.SetString(key, JsonSerializer.Serialize(values)); } catch { @@ -98,5 +95,3 @@ private static void Save(ISession session, string key, List values) } } } - - diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs new file mode 100644 index 0000000..61c894d --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/HttpFormSessionStore.cs @@ -0,0 +1,22 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace GovUK.Dfe.FlexForms.Infrastructure.Stores; + +/// +/// HTTP-session adapter for . +/// +public sealed class HttpFormSessionStore(IHttpContextAccessor httpContextAccessor) : IFormSessionStore +{ + private ISession Session => + httpContextAccessor.HttpContext?.Session + ?? throw new InvalidOperationException("HTTP session is not available."); + + public string? GetString(string key) => Session.GetString(key); + + public void SetString(string key, string value) => Session.SetString(key, value); + + public void Remove(string key) => Session.Remove(key); + + public IReadOnlyCollection Keys => Session.Keys.ToList(); +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs new file mode 100644 index 0000000..f87bae0 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Stores/RedisInfectedFileStore.cs @@ -0,0 +1,49 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace GovUK.Dfe.FlexForms.Infrastructure.Stores; + +/// +/// Redis adapter for the malware-scan file blacklist. +/// +public sealed class RedisInfectedFileStore( + IConnectionMultiplexer redis, + ILogger logger) : IInfectedFileStore +{ + public bool IsFileInfected(Guid fileId) + { + try + { + var key = $"{FlexFormsCacheKeys.InfectedFilePrefix}{fileId}"; + return redis.GetDatabase().KeyExists(key); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to check infected-file blacklist for {FileId}", fileId); + return false; + } + } + + public bool IsFileNameInfected(string applicationId, string originalFileName) + { + if (string.IsNullOrWhiteSpace(applicationId) || string.IsNullOrWhiteSpace(originalFileName)) + return false; + + try + { + var key = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{applicationId}:{originalFileName}"; + return redis.GetDatabase().KeyExists(key); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to check infected-filename blacklist for {ApplicationId}/{FileName}", + applicationId, + originalFileName); + return false; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs new file mode 100644 index 0000000..608ea9c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormValidationResultExtensions.cs @@ -0,0 +1,21 @@ +using GovUK.Dfe.FlexForms.Application.Validation; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace GovUK.Dfe.FlexForms.Web.Extensions; + +/// +/// Maps Application validation results onto ASP.NET ModelState. +/// +public static class FormValidationResultExtensions +{ + public static bool ApplyTo(this FormValidationResult result, ModelStateDictionary modelState) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(modelState); + + foreach (var error in result.Errors) + modelState.AddModelError(error.FieldKey, error.Message); + + return result.IsValid; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs index 3302144..ce5c12d 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs @@ -74,6 +74,8 @@ public static IServiceCollection AddWebLayerServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs index 6122174..c19a828 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors-Invite.cshtml.cs @@ -48,7 +48,7 @@ public async Task OnGetAsync() { // Ensure we have a valid application ID - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -65,7 +65,7 @@ public async Task OnGetAsync() /// public async Task OnPostSendInviteAsync() { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -105,7 +105,7 @@ public async Task OnPostCancel() { logger.LogInformation("User cancelled contributor invitation for application reference {ReferenceNumber}", ReferenceNumber); - var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) { diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs index 1049916..7dfe5b8 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Contributors.cshtml.cs @@ -34,7 +34,7 @@ public class ContributorsModel( /// public async Task OnGetAsync() { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) @@ -66,7 +66,7 @@ public IActionResult OnPostProceedToForm() /// public async Task OnPostAddContributor() { - var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (_, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); var redirect = await RedirectIfContributorPatternDisabledAsync(application); if (redirect != null) { @@ -86,7 +86,7 @@ public async Task OnPostRemoveContributorAsync(Guid contributorId { if (!ApplicationId.HasValue) { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; var redirect = await RedirectIfContributorPatternDisabledAsync(application); @@ -169,7 +169,7 @@ public async Task OnGetRemoveContributorAsync() if (!ApplicationId.HasValue) { - var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; var redirect = await RedirectIfContributorPatternDisabledAsync(application); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs index 8ea24cd..7de385b 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Applications/Dashboard.cshtml.cs @@ -189,7 +189,7 @@ public async Task OnPostCreateApplicationAsync() } // Clear any existing accumulated form data when starting a new application - applicationResponseService.ClearAccumulatedFormData(HttpContext.Session); + applicationResponseService.ClearAccumulatedFormData(); HttpContext.Session.SetString("CurrentAccumulatedApplicationId", response.ApplicationId.ToString()); if (User.Identity?.IsAuthenticated == true) diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs index 6649731..22af0e9 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/BaseFormEngineModel.cs @@ -1,5 +1,6 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Extensions; using GovUK.Dfe.FlexForms.Web.Pages.Shared; using GovUK.Dfe.FlexForms.Web.Services; using Microsoft.AspNetCore.Mvc; @@ -116,7 +117,7 @@ protected string GetTaskListUrl() /// True if validation passes protected bool ValidateCurrentPage(Domain.Models.Page page, Dictionary data) { - return _formValidationOrchestrator.ValidatePage(page, data, ModelState, Template); + return _formValidationOrchestrator.ValidatePage(page, data, Template).ApplyTo(ModelState); } /// @@ -127,7 +128,7 @@ protected bool ValidateCurrentPage(Domain.Models.Page page, DictionaryTrue if validation passes protected bool ValidateCurrentTask(Domain.Models.Task task, Dictionary data) { - return _formValidationOrchestrator.ValidateTask(task, data, ModelState, Template); + return _formValidationOrchestrator.ValidateTask(task, data, Template).ApplyTo(ModelState); } /// diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs index 773a2af..0d76d51 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RemoveFieldItem.cshtml.cs @@ -20,7 +20,7 @@ public async Task OnPostRemoveFieldItemAsync(string referenceNumb return BadRequest("Field ID and valid index are required"); } - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var acc = _applicationResponseService.GetAccumulatedFormData(); if (acc.TryGetValue(fieldId, out var existing)) { var json = existing?.ToString() ?? "[]"; @@ -31,7 +31,7 @@ public async Task OnPostRemoveFieldItemAsync(string referenceNumb { list.RemoveAt(index); var updated = JsonSerializer.Serialize(list); - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updated }, HttpContext.Session); + _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updated }); } } catch (Exception ex) diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs index 75aaadb..873e570 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs @@ -1,7 +1,6 @@ using GovUK.Dfe.FlexForms.Application.Exceptions; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Notifications; -using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Infrastructure.Services; using GovUK.Dfe.FlexForms.Web.Constants; @@ -14,7 +13,6 @@ using GovUK.Dfe.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.AspNetCore.Mvc; -using StackExchange.Redis; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; @@ -46,7 +44,7 @@ public class RenderFormModel( IComplexFieldConfigurationService complexFieldConfigurationService, IDerivedCollectionFlowService derivedCollectionFlowService, IFieldRequirementService fieldRequirementService, - IConnectionMultiplexer redis, + IInfectedFileStore infectedFileStore, ILogger logger, INavigationHistoryService navigationHistoryService, IRequestAppConfiguration requestConfiguration) @@ -59,7 +57,7 @@ public class RenderFormModel( private readonly IFormErrorStore _formErrorStore = formErrorStore; private readonly IComplexFieldConfigurationService _complexFieldConfigurationService = complexFieldConfigurationService; private readonly IDerivedCollectionFlowService _derivedCollectionFlowService = derivedCollectionFlowService; - private readonly IConnectionMultiplexer _redis = redis; + private readonly IInfectedFileStore _infectedFileStore = infectedFileStore; private readonly IFieldRequirementService _fieldRequirementService = fieldRequirementService; private readonly INavigationHistoryService _navigationHistoryService = navigationHistoryService; private readonly IRequestAppConfiguration _requestConfiguration = requestConfiguration; @@ -352,7 +350,7 @@ public async Task OnGetAsync() if (Request.Query.ContainsKey("nav") && string.Equals(Request.Query["nav"], "back", StringComparison.OrdinalIgnoreCase)) { var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Pop(scope, HttpContext.Session); + _navigationHistoryService.Pop(scope); } } catch { } @@ -525,17 +523,17 @@ public async Task OnPostTaskSummaryAsync() } // Mark the task as completed in session and API - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, Domain.Models.TaskStatus.Completed, HttpContext.Session); + await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, Domain.Models.TaskStatus.Completed); } else { // Task was unchecked - set it back to in progress if it has data, otherwise not started - var currentStatus = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); + var currentStatus = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, ApplicationStatus); if (currentStatus == Domain.Models.TaskStatus.Completed) { // Only override if it was explicitly marked as completed - revert to calculated status var calculatedStatus = HasAnyTaskData(CurrentTask) ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, calculatedStatus, HttpContext.Session); + await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, calculatedStatus); } } } @@ -872,7 +870,7 @@ public async Task OnPostPageAsync() if (IsCollectionFlow) { var flowProgress = LoadFlowProgress(FlowId, InstanceId); - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = _applicationResponseService.GetAccumulatedFormData(); foreach (var key in Data.Keys.ToList()) { @@ -1088,7 +1086,7 @@ public async Task OnPostPageAsync() } // Load existing selections from accumulated session - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var acc = _applicationResponseService.GetAccumulatedFormData(); var list = new List(); if (acc.TryGetValue(key, out var existing) && !string.IsNullOrWhiteSpace(existing?.ToString())) { @@ -1166,7 +1164,7 @@ public async Task OnPostPageAsync() // Update both normalized and Data_ forms to be safe Data[key] = updatedJson; Data[$"Data_{key}"] = updatedJson; - _applicationResponseService.AccumulateFormData(new Dictionary { [key] = updatedJson }, HttpContext.Session); + _applicationResponseService.AccumulateFormData(new Dictionary { [key] = updatedJson }); } } catch (Exception ex) @@ -1180,7 +1178,7 @@ public async Task OnPostPageAsync() bool isDerivedFlowSave = TryParseDerivedFlowRoute(CurrentPageId, out _, out _, out _); if (ApplicationId.HasValue && Data.Any() && !isSubFlow && !isDerivedFlowSave) { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, Data, HttpContext.Session); + await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, Data); _logger.LogInformation("Successfully saved response for Application {ApplicationId}, Page {PageId}", ApplicationId.Value, CurrentPageId); } @@ -1192,13 +1190,13 @@ public async Task OnPostPageAsync() { var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}/{CurrentPageId}"; - _navigationHistoryService.Push(scope, currentUrl, HttpContext.Session); + _navigationHistoryService.Push(scope, currentUrl); } else if (!string.IsNullOrEmpty(TaskId)) { var scope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); var currentUrl = $"/applications/{ReferenceNumber}/{TaskId}"; - _navigationHistoryService.Push(scope, currentUrl, HttpContext.Session); + _navigationHistoryService.Push(scope, currentUrl); } } catch { } @@ -1231,13 +1229,12 @@ public async Task OnPostPageAsync() var accumulatedProgress = LoadFlowProgress(flowId, instanceId); AppendCollectionItemToSession(flowPages, flowFieldId, instanceId, accumulatedProgress); - var accData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accData = _applicationResponseService.GetAccumulatedFormData(); if (accData.TryGetValue(flowFieldId, out var collectionValue)) { await _applicationResponseService.SaveApplicationResponseAsync( ApplicationId.Value, - new Dictionary { [flowFieldId] = collectionValue }, - HttpContext.Session); + new Dictionary { [flowFieldId] = collectionValue }); _logger.LogInformation("Saved partial collection item to database for flow {FlowId}, instance {InstanceId}, page {PageId}", flowId, instanceId, CurrentPageId); } @@ -1336,10 +1333,10 @@ await _applicationResponseService.SaveApplicationResponseAsync( if (ApplicationId.HasValue) { // Trigger save for the collection field - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var acc = _applicationResponseService.GetAccumulatedFormData(); if (acc.TryGetValue(flowFieldId, out var collectionValue)) { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [flowFieldId] = collectionValue }, HttpContext.Session); + await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [flowFieldId] = collectionValue }); } } // Clear the in-progress cache for this instance @@ -1347,7 +1344,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( // Clear navigation history var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(scope, HttpContext.Session); + _navigationHistoryService.Clear(scope); } var backToSummary = _formNavigationService.GetCollectionFlowSummaryUrl(CurrentTask.TaskId, ReferenceNumber); return Redirect(backToSummary); @@ -1414,7 +1411,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( [statusKey] = FormData[statusKey], [dataKey] = FormData[dataKey] }; - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, derivedUpdates, HttpContext.Session); + await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, derivedUpdates); } else { @@ -1458,7 +1455,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [$"{TaskId}_completed"] = true - }, HttpContext.Session); + }); // Also set the task status to Completed (matches TaskSummary behaviour) if (CurrentTask != null) @@ -1466,8 +1463,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( await _applicationStateService.SaveTaskStatusAsync( ApplicationId.Value, CurrentTask.TaskId, - Domain.Models.TaskStatus.Completed, - HttpContext.Session); + Domain.Models.TaskStatus.Completed); } _logger.LogInformation("POST: About to redirect to task list using RedirectToPage with ReferenceNumber: {ReferenceNumber}", ReferenceNumber); @@ -1480,10 +1476,10 @@ await _applicationStateService.SaveTaskStatusAsync( // If unchecked: set task status based on calculated state (in progress if any data exists, else not started) if (CurrentTask != null && ApplicationId.HasValue) { - var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus) + var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, ApplicationStatus) != Domain.Models.TaskStatus.NotStarted; var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; - await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, newStatus, HttpContext.Session); + await _applicationStateService.SaveTaskStatusAsync(ApplicationId.Value, CurrentTask.TaskId, newStatus); } @@ -1541,7 +1537,7 @@ await _applicationStateService.SaveTaskStatusAsync( // No conditional override - respect returnToSummaryPage var summaryScope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(summaryScope, HttpContext.Session); + _navigationHistoryService.Clear(summaryScope); var summaryUrl = _formNavigationService.GetTaskSummaryUrl(CurrentTask.TaskId, ReferenceNumber); @@ -1600,7 +1596,7 @@ await _applicationStateService.SaveTaskStatusAsync( // No next page found - go to task summary as fallback var summaryFallbackScope = RenderFormModel.BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); - _navigationHistoryService.Clear(summaryFallbackScope, HttpContext.Session); + _navigationHistoryService.Clear(summaryFallbackScope); var fallbackUrl = _formNavigationService.GetTaskSummaryUrl(CurrentTask.TaskId, ReferenceNumber); @@ -1694,19 +1690,17 @@ await _applicationStateService.SaveTaskStatusAsync( await _applicationStateService.SaveTaskStatusAsync( ApplicationId.Value, CurrentTask.TaskId, - Domain.Models.TaskStatus.Completed, - HttpContext.Session); + Domain.Models.TaskStatus.Completed); } else { - var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus) + var hasAnyData = _applicationStateService.CalculateTaskStatus(CurrentTask.TaskId, Template, FormData, ApplicationId, ApplicationStatus) != Domain.Models.TaskStatus.NotStarted; var newStatus = hasAnyData ? Domain.Models.TaskStatus.InProgress : Domain.Models.TaskStatus.NotStarted; await _applicationStateService.SaveTaskStatusAsync( ApplicationId.Value, CurrentTask.TaskId, - newStatus, - HttpContext.Session); + newStatus); } } @@ -1783,7 +1777,7 @@ public async Task OnPostRemoveCollectionItemAsync(string fieldId, _logger.LogInformation("RemoveCollectionItem handler executing confirmed removal for item {ItemId} from field {FieldId}", itemId, fieldId); // Get current collection from session first - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = _applicationResponseService.GetAccumulatedFormData(); Dictionary? itemData = null; string? flowTitle = null; @@ -1839,12 +1833,12 @@ public async Task OnPostRemoveCollectionItemAsync(string fieldId, // Update the collection var updatedJson = JsonSerializer.Serialize(items); - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updatedJson }, HttpContext.Session); + _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = updatedJson }); // Save to API if (ApplicationId.HasValue) { - await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [fieldId] = updatedJson }, HttpContext.Session); + await _applicationResponseService.SaveApplicationResponseAsync(ApplicationId.Value, new Dictionary { [fieldId] = updatedJson }); } } catch (ExternalApplicationsException) @@ -2052,7 +2046,7 @@ private string GetDerivedItemDisplayName(DerivedCollectionFlowConfiguration conf /// private bool IsExistingCollectionItem(string fieldId, string instanceId) { - var accumulated = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulated = _applicationResponseService.GetAccumulatedFormData(); if (accumulated.TryGetValue(fieldId, out var collectionValue)) { var json = collectionValue?.ToString() ?? "[]"; @@ -2124,7 +2118,7 @@ private bool HasAnyTaskData(Domain.Models.Task task) private void AppendCollectionItemToSession(List pages, string fieldId, string instanceId, Dictionary itemData) { - var acc = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var acc = _applicationResponseService.GetAccumulatedFormData(); var list = new List>(); if (acc.TryGetValue(fieldId, out var existing)) { @@ -2211,7 +2205,7 @@ private void AppendCollectionItemToSession(List pages, strin var serialized = JsonSerializer.Serialize(list); - _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = serialized }, HttpContext.Session); + _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = serialized }); } private static string GetFlowProgressSessionKey(string flowId, string instanceId) => $"FlowProgress_{flowId}_{instanceId}"; @@ -2318,7 +2312,7 @@ private void CheckAndClearSessionForNewApplication() sessionApplicationId != currentApplicationId) { // Clear accumulated data for the previous application - _applicationResponseService.ClearAccumulatedFormData(HttpContext.Session); + _applicationResponseService.ClearAccumulatedFormData(); _logger.LogInformation("Cleared accumulated form data for previous application {PreviousApplicationId}, now working with {CurrentApplicationId}", sessionApplicationId, currentApplicationId); } @@ -2334,7 +2328,7 @@ private async Task LoadAccumulatedDataFromSessionAsync() { // Get accumulated form data from session and populate the Data dictionary // Infected files are automatically filtered by the blacklist - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = _applicationResponseService.GetAccumulatedFormData(); if (accumulatedData.Any()) { @@ -2368,7 +2362,7 @@ private async Task ApplyConditionalLogicAsync(string trigger = "load") // Only merge when in POST/change trigger (not during initial GET/load) if (trigger == "change") { - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = _applicationResponseService.GetAccumulatedFormData(); foreach (var kvp in accumulatedData) { // Only add if not already in dataForConditionalLogic (current page data takes priority) @@ -2444,7 +2438,7 @@ private void LoadExistingFlowItemData(string flowId, string instanceId) if (string.IsNullOrEmpty(fieldId)) return; - var accumulated = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulated = _applicationResponseService.GetAccumulatedFormData(); if (accumulated.TryGetValue(fieldId, out var collectionValue)) { var json = collectionValue?.ToString() ?? "[]"; @@ -3198,7 +3192,6 @@ public List FilterInfectedFilesFromList(List files) try { - var db = _redis.GetDatabase(); var infectedFileIds = new HashSet(); var appId = ApplicationId?.ToString() ?? HttpContext.Session.GetString("ApplicationId"); @@ -3207,26 +3200,18 @@ public List FilterInfectedFilesFromList(List files) files.Count, appId); - // Check each file against BOTH blacklist types: - // 1. By file ID (FlexForms:InfectedFile:{fileId}) - // 2. By filename (FlexForms:InfectedFileName:{applicationId}:{originalFileName}) foreach (var file in files) { - // Check by file ID - var fileIdBlacklistKey = $"{FlexFormsCacheKeys.InfectedFilePrefix}{file.Id}"; - var fileIdExists = db.KeyExists(fileIdBlacklistKey); - - // Check by filename (fallback when file ID doesn't match) - var filenameBlacklistKey = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{appId}:{file.OriginalFileName}"; - var filenameExists = db.KeyExists(filenameBlacklistKey); + var fileIdExists = _infectedFileStore.IsFileInfected(file.Id); + var filenameExists = !string.IsNullOrEmpty(appId) + && !string.IsNullOrEmpty(file.OriginalFileName) + && _infectedFileStore.IsFileNameInfected(appId, file.OriginalFileName); _logger.LogInformation( - "FilterInfectedFilesFromList: File {FileId} ({FileName}) - FileIdKey='{FileIdKey}' exists={FileIdExists}, FilenameKey='{FilenameKey}' exists={FilenameExists}", + "FilterInfectedFilesFromList: File {FileId} ({FileName}) - FileIdInfected={FileIdExists}, FilenameInfected={FilenameExists}", file.Id, file.OriginalFileName, - fileIdBlacklistKey, fileIdExists, - filenameBlacklistKey, filenameExists); if (fileIdExists || filenameExists) @@ -3341,7 +3326,7 @@ private async Task> GetFilesForFieldAsync(Guid appId, s // This handles the initial load or page refresh scenarios try { - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); foreach (var kvp in accumulatedData) @@ -3462,7 +3447,7 @@ private async Task> GetFilesForFieldAsync(Guid appId, s try { _logger.LogInformation("GetFilesForFieldAsync: REGULAR FORM - Falling back to accumulated data"); - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) { @@ -3532,9 +3517,7 @@ private bool FileExistInSessionList(Guid appId, string fieldId, string fileName) // If it is, we should ALLOW re-upload (the old infected file should be replaced) try { - var db = _redis.GetDatabase(); - var filenameBlacklistKey = $"{FlexFormsCacheKeys.InfectedFileNamePrefix}{appId}:{fileName}"; - if (db.KeyExists(filenameBlacklistKey)) + if (_infectedFileStore.IsFileNameInfected(appId.ToString(), fileName)) { _logger.LogInformation( "File '{FileName}' is in infected blacklist, allowing re-upload", @@ -3613,7 +3596,7 @@ private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, var json = JsonSerializer.Serialize(files); var data = new Dictionary { { fieldId, json } }; - await _applicationResponseService.SaveApplicationResponseAsync(appId, data, HttpContext.Session); + await _applicationResponseService.SaveApplicationResponseAsync(appId, data); } /// diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs index ecc63ba..701da5b 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs @@ -288,7 +288,7 @@ private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, var json = JsonSerializer.Serialize(files); var data = new Dictionary { { fieldId, json } }; - await applicationResponseService.SaveApplicationResponseAsync(appId, data, HttpContext.Session); + await applicationResponseService.SaveApplicationResponseAsync(appId, data); } /// @@ -340,7 +340,7 @@ private async Task> GetFilesForFieldAsync(Guid appId, s // 2. If still no files, check accumulated form data if (string.IsNullOrWhiteSpace(sessionFilesJson)) { - var alternativeAccumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var alternativeAccumulatedData = applicationResponseService.GetAccumulatedFormData(); if (alternativeAccumulatedData.TryGetValue(fieldId, out var accFieldValue)) { sessionFilesJson = accFieldValue?.ToString(); @@ -419,7 +419,7 @@ private async Task> GetFilesForFieldAsync(Guid appId, s } // If no session data, try to get from accumulated form data (for existing applications) - var accumulatedData = applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) { var fieldValueStr = fieldValue?.ToString(); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs index 2999dcf..6bc610c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/Shared/BaseFormPageModel.cs @@ -51,7 +51,7 @@ public abstract class BaseFormPageModel( /// protected async Task EnsureApplicationIdAsync() { - var (applicationId, application) = await _applicationStateService.EnsureApplicationIdAsync(ReferenceNumber, HttpContext.Session); + var (applicationId, application) = await _applicationStateService.EnsureApplicationIdAsync(ReferenceNumber); ApplicationId = applicationId; CurrentApplication = application; } @@ -69,7 +69,7 @@ protected async Task LoadTemplateAsync() /// protected void LoadFormDataFromSession() { - FormData = _applicationResponseService.GetAccumulatedFormData(HttpContext.Session); + FormData = _applicationResponseService.GetAccumulatedFormData(); } /// @@ -77,7 +77,7 @@ protected void LoadFormDataFromSession() /// protected void LoadApplicationStatus() { - ApplicationStatus = _applicationStateService.GetApplicationStatus(ApplicationId, HttpContext.Session); + ApplicationStatus = _applicationStateService.GetApplicationStatus(ApplicationId); } /// @@ -222,7 +222,7 @@ public bool HasFieldValue(string fieldId) /// public Domain.Models.TaskStatus GetTaskStatusFromSession(string taskId) { - return _applicationStateService.CalculateTaskStatus(taskId, Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); + return _applicationStateService.CalculateTaskStatus(taskId, Template, FormData, ApplicationId, ApplicationStatus); } /// @@ -230,7 +230,7 @@ public Domain.Models.TaskStatus GetTaskStatusFromSession(string taskId) /// public bool AreAllTasksCompleted() { - return _applicationStateService.AreAllTasksCompleted(Template, FormData, ApplicationId, HttpContext.Session, ApplicationStatus); + return _applicationStateService.AreAllTasksCompleted(Template, FormData, ApplicationId, ApplicationStatus); } /// diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml index f2cdec3..c8427fc 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/Fields/_UploadComplexField.cshtml @@ -3,7 +3,7 @@ @using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response @inject IHttpContextAccessor httpContext; @inject GovUK.Dfe.FlexForms.Web.Interfaces.IFormErrorStore FormErrorStore -@inject StackExchange.Redis.IConnectionMultiplexer redis +@inject GovUK.Dfe.FlexForms.Application.Interfaces.IInfectedFileStore infectedFileStore @using Microsoft.AspNetCore.Http.Extensions @using Microsoft.AspNetCore.Http @using System.Linq @@ -49,23 +49,14 @@ try { - var db = redis.GetDatabase(); var infectedFileIds = new HashSet(); var appId = ViewData["applicationId"] as string ?? Context.Session.GetString("ApplicationId"); - - // Check each file against BOTH blacklist types: - // 1. By file ID (DfE:InfectedFile:{fileId}) - // 2. By filename (DfE:InfectedFileName:{applicationId}:{originalFileName}) + foreach (var file in files) { - // Check by file ID - var fileIdBlacklistKey = $"DfE:InfectedFile:{file.Id}"; - var fileIdExists = db.KeyExists(fileIdBlacklistKey); - - // Check by filename (fallback when file ID doesn't match) - var filenameBlacklistKey = $"DfE:InfectedFileName:{appId}:{file.OriginalFileName}"; - var filenameExists = db.KeyExists(filenameBlacklistKey); - + var fileIdExists = infectedFileStore.IsFileInfected(file.Id); + var filenameExists = infectedFileStore.IsFileNameInfected(appId ?? string.Empty, file.OriginalFileName ?? string.Empty); + if (fileIdExists || filenameExists) { infectedFileIds.Add(file.Id); diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj index b184fc0..b7009bf 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj @@ -10,6 +10,11 @@ + + + + + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs index 77e40fb..bb044b9 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/ApplicationStateServiceTests.cs @@ -5,7 +5,6 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; using GovUK.Dfe.FlexForms.Api.Client.Contracts; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -18,39 +17,39 @@ public class ApplicationStateServiceTests private readonly IApplicationResponseService _applicationResponseService = Substitute.For(); private readonly IFieldRequirementService _fieldRequirementService = Substitute.For(); - private ApplicationStateService CreateService() => - new(_applicationsClient, _applicationResponseService, _fieldRequirementService, NullLogger.Instance); + private ApplicationStateService CreateService(IFormSessionStore sessionStore) => + new(_applicationsClient, _applicationResponseService, _fieldRequirementService, sessionStore, NullLogger.Instance); [Fact] public async Task EnsureApplicationIdAsync_AlwaysCallsApi_EvenWhenSessionHasCachedApplication() { const string reference = "APP-001"; var applicationId = Guid.NewGuid(); - var session = CreateSession(session => + var sessionStore = CreateSessionStore(store => { - session.SetString("ApplicationId", applicationId.ToString()); - session.SetString("ApplicationReference", reference); - session.SetString($"TemplateSchema_{reference}", "{\"templateId\":\"t1\"}"); - session.SetString($"TemplateVersionId_{reference}", Guid.NewGuid().ToString()); + store.SetString("ApplicationId", applicationId.ToString()); + store.SetString("ApplicationReference", reference); + store.SetString($"TemplateSchema_{reference}", "{\"templateId\":\"t1\"}"); + store.SetString($"TemplateVersionId_{reference}", Guid.NewGuid().ToString()); }); var apiApplication = CreateApplication(reference, applicationId); _applicationsClient.GetApplicationByReferenceAsync(reference).Returns(apiApplication); - var service = CreateService(); - var (returnedId, returnedApplication) = await service.EnsureApplicationIdAsync(reference, session); + var service = CreateService(sessionStore); + var (returnedId, returnedApplication) = await service.EnsureApplicationIdAsync(reference); Assert.Equal(applicationId, returnedId); Assert.Same(apiApplication, returnedApplication); await _applicationsClient.Received(1).GetApplicationByReferenceAsync(reference); - _applicationResponseService.Received(1).ClearAccumulatedFormData(session); + _applicationResponseService.Received(1).ClearAccumulatedFormData(); } [Fact] public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_WhenApiReturns404() { const string reference = "APP-MISSING"; - var session = CreateSession(); + var sessionStore = CreateSessionStore(); _applicationsClient.GetApplicationByReferenceAsync(reference) .Throws(new ExternalApplicationsException( @@ -61,10 +60,10 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When new ExceptionResponse { StatusCode = 404 }, null)); - var service = CreateService(); + var service = CreateService(sessionStore); var exception = await Assert.ThrowsAsync( - () => service.EnsureApplicationIdAsync(reference, session)); + () => service.EnsureApplicationIdAsync(reference)); Assert.Equal(reference, exception.ApplicationReference); } @@ -76,7 +75,7 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When [InlineData("Deleted", false)] public void IsApplicationEditable_AllowsCreatedAndInProgress(string status, bool expected) { - var service = CreateService(); + var service = CreateService(CreateSessionStore()); Assert.Equal(expected, service.IsApplicationEditable(status)); } @@ -85,7 +84,7 @@ public void IsApplicationEditable_AllowsCreatedAndInProgress(string status, bool public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_WhenApiReturns403() { const string reference = "APP-FORBIDDEN"; - var session = CreateSession(); + var sessionStore = CreateSessionStore(); _applicationsClient.GetApplicationByReferenceAsync(reference) .Throws(new ExternalApplicationsException( @@ -96,30 +95,30 @@ public async Task EnsureApplicationIdAsync_ThrowsApplicationAccessException_When new ExceptionResponse { StatusCode = 403 }, null)); - var service = CreateService(); + var service = CreateService(sessionStore); await Assert.ThrowsAsync( - () => service.EnsureApplicationIdAsync(reference, session)); + () => service.EnsureApplicationIdAsync(reference)); } [Fact] public async Task EnsureApplicationIdAsync_ClearsFormData_WhenReferenceChanges() { - var session = CreateSession(session => + var sessionStore = CreateSessionStore(store => { - session.SetString("ApplicationReference", "APP-OLD"); - session.SetString("ApplicationId", Guid.NewGuid().ToString()); + store.SetString("ApplicationReference", "APP-OLD"); + store.SetString("ApplicationId", Guid.NewGuid().ToString()); }); const string newReference = "APP-NEW"; var apiApplication = CreateApplication(newReference, Guid.NewGuid()); _applicationsClient.GetApplicationByReferenceAsync(newReference).Returns(apiApplication); - var service = CreateService(); - await service.EnsureApplicationIdAsync(newReference, session); + var service = CreateService(sessionStore); + await service.EnsureApplicationIdAsync(newReference); - _applicationResponseService.Received(1).ClearAccumulatedFormData(session); - Assert.Equal(newReference, session.GetString("ApplicationReference")); + _applicationResponseService.Received(2).ClearAccumulatedFormData(); + Assert.Equal(newReference, sessionStore.GetString("ApplicationReference")); } private static ApplicationDto CreateApplication(string reference, Guid applicationId) => @@ -144,32 +143,23 @@ private static ApplicationDto CreateApplication(string reference, Guid applicati } }; - private static ISession CreateSession(Action? configure = null) + private static InMemoryFormSessionStore CreateSessionStore(Action? configure = null) { - var session = new TestSession(); - configure?.Invoke(session); - return session; + var store = new InMemoryFormSessionStore(); + configure?.Invoke(store); + return store; } - private sealed class TestSession : ISession + private sealed class InMemoryFormSessionStore : IFormSessionStore { - private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); - private bool _isAvailable = true; + private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); - public bool IsAvailable => _isAvailable; - public string Id { get; set; } = Guid.NewGuid().ToString(); - public IEnumerable Keys => _store.Keys; + public string? GetString(string key) => _store.TryGetValue(key, out var value) ? value : null; - public void Clear() => _store.Clear(); - - public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void SetString(string key, string value) => _store[key] = value; public void Remove(string key) => _store.Remove(key); - public void Set(string key, byte[] value) => _store[key] = value; - - public bool TryGetValue(string key, out byte[] value) => _store.TryGetValue(key, out value!); + public IReadOnlyCollection Keys => _store.Keys.ToList(); } } diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs index 17390c5..e649796 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Services/FormValidationOrchestratorTests.cs @@ -1,8 +1,9 @@ using AutoFixture; using AutoFixture.AutoNSubstitute; +using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Infrastructure.Services; -using Microsoft.AspNetCore.Mvc.ModelBinding; +using NSubstitute; namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Services; @@ -16,6 +17,10 @@ public FormValidationOrchestratorTests() _fixture = new Fixture().Customize(new AutoNSubstituteCustomization { ConfigureMembers = true }); _fixture.Customize(ob => ob.Without(rule => rule.Conditions)); + + var fieldRequirementService = Substitute.For(); + fieldRequirementService.IsFieldRequired(Arg.Any(), Arg.Any()).Returns(false); + _fixture.Register(() => fieldRequirementService); _orchestrator = _fixture.Create(); } @@ -43,13 +48,12 @@ public void ValidateField_when_required_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.True(result); + Assert.True(result.IsValid); } [Theory] @@ -75,15 +79,13 @@ public void ValidateField_when_required_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.False(result); - Assert.NotNull(modelState[fieldKey]); - Assert.Equal("This field is required", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("This field is required", result.Errors[0].Message); } [Theory] @@ -104,15 +106,13 @@ public void ValidateField_when_optional_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, "not-an-option", formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, "not-an-option", formData, fieldKey, formTemplate); - Assert.False(result); - Assert.NotNull(modelState[fieldKey]); - Assert.Equal("Select an option from the list", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("Select an option from the list", result.Errors[0].Message); } [Theory] @@ -135,14 +135,13 @@ public void ValidateField_when_optional_field_with_options_and_submittedValue_is .Create(); var formData = _fixture.Create?>(); - var modelState = _fixture.Create(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, submittedValue, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedValue, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -160,17 +159,16 @@ public void ValidateField_when_maxLength_and_submitted_value_contains_html_entit .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); // User sees five characters (EMAT + U+2019); submitted value may arrive as an HTML numeric character reference. const string submittedEncoded = "EMAT’"; - var result = _orchestrator.ValidateField(field, submittedEncoded, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedEncoded, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -188,16 +186,15 @@ public void ValidateField_when_maxLength_and_decoded_length_exceeds_limit_then_r .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); const string submittedEncoded = "EMAT’"; - var result = _orchestrator.ValidateField(field, submittedEncoded, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, submittedEncoded, formData, fieldKey, formTemplate); - Assert.False(result); - Assert.Equal("Too many characters", modelState[fieldKey]!.Errors[0].ErrorMessage); + Assert.False(result.IsValid); + Assert.Equal("Too many characters", result.Errors[0].Message); } [Fact] @@ -219,14 +216,13 @@ public void ValidateField_when_maxLength_and_value_is_sanitised_with_br_tags_the .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, fieldKey, formTemplate); - Assert.True(result); - Assert.Null(modelState[fieldKey]); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); } [Fact] @@ -246,13 +242,12 @@ public void ValidateField_when_maxLength_and_sanitised_value_exceeds_plain_limit .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, modelState, fieldKey, formTemplate); + var result = _orchestrator.ValidateField(field, sanitisedAsStored, formData, fieldKey, formTemplate); - Assert.False(result); + Assert.False(result.IsValid); } [Fact] @@ -301,11 +296,10 @@ private bool ValidateFieldWordCount(string sanitisedText, short limit) .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - return _orchestrator.ValidateField(field, sanitisedText, formData, modelState, fieldKey, formTemplate); + return _orchestrator.ValidateField(field, sanitisedText, formData, fieldKey, formTemplate).IsValid; } private bool ValidateComplexFieldWordCount(string sanitisedText, short limit) @@ -322,10 +316,9 @@ private bool ValidateComplexFieldWordCount(string sanitisedText, short limit) .Create(); var formData = _fixture.Create?>(); - var modelState = new ModelStateDictionary(); var fieldKey = field.FieldId; var formTemplate = _fixture.Create(); - return _orchestrator.ValidateField(field, sanitisedText, formData, modelState, fieldKey, formTemplate); + return _orchestrator.ValidateField(field, sanitisedText, formData, fieldKey, formTemplate).IsValid; } } \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs new file mode 100644 index 0000000..8ae9cb3 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/HttpFormSessionStoreTests.cs @@ -0,0 +1,52 @@ +using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Stores; + +public class HttpFormSessionStoreTests +{ + [Fact] + public void GetString_SetString_Remove_round_trip_http_session() + { + var httpContext = new DefaultHttpContext(); + httpContext.Session = new MemorySession(); + var accessor = Substitute.For(); + accessor.HttpContext.Returns(httpContext); + + var store = new HttpFormSessionStore(accessor); + + store.SetString("TemplateId", "abc"); + Assert.Equal("abc", store.GetString("TemplateId")); + Assert.Contains("TemplateId", store.Keys); + + store.Remove("TemplateId"); + Assert.Null(store.GetString("TemplateId")); + Assert.DoesNotContain("TemplateId", store.Keys); + } + + [Fact] + public void GetString_throws_when_http_context_is_missing() + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns((HttpContext?)null); + var store = new HttpFormSessionStore(accessor); + + Assert.Throws(() => store.GetString("any")); + } + + private sealed class MemorySession : ISession + { + private readonly Dictionary _store = new(StringComparer.Ordinal); + + public bool IsAvailable => true; + public string Id => "test"; + public IEnumerable Keys => _store.Keys; + public void Clear() => _store.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _store.Remove(key); + public void Set(string key, byte[] value) => _store[key] = value; + public bool TryGetValue(string key, out byte[] value) => _store.TryGetValue(key, out value!); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs new file mode 100644 index 0000000..347e853 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Infrastructure.UnitTests/Stores/RedisInfectedFileStoreTests.cs @@ -0,0 +1,51 @@ +using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Infrastructure.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StackExchange.Redis; + +namespace GovUK.Dfe.FlexForms.Infrastructure.UnitTests.Stores; + +public class RedisInfectedFileStoreTests +{ + [Fact] + public void IsFileInfected_returns_true_when_blacklist_key_exists() + { + var fileId = Guid.NewGuid(); + var database = Substitute.For(); + database.KeyExists($"{FlexFormsCacheKeys.InfectedFilePrefix}{fileId}", Arg.Any()).Returns(true); + + var store = CreateStore(database); + + Assert.True(store.IsFileInfected(fileId)); + } + + [Fact] + public void IsFileNameInfected_returns_true_when_filename_blacklist_key_exists() + { + const string applicationId = "app-1"; + const string fileName = "malware.exe"; + var database = Substitute.For(); + database.KeyExists($"{FlexFormsCacheKeys.InfectedFileNamePrefix}{applicationId}:{fileName}", Arg.Any()).Returns(true); + + var store = CreateStore(database); + + Assert.True(store.IsFileNameInfected(applicationId, fileName)); + } + + [Fact] + public void IsFileNameInfected_returns_false_when_application_or_filename_is_missing() + { + var store = CreateStore(Substitute.For()); + + Assert.False(store.IsFileNameInfected("", "file.pdf")); + Assert.False(store.IsFileNameInfected("app-1", "")); + } + + private static RedisInfectedFileStore CreateStore(IDatabase database) + { + var redis = Substitute.For(); + redis.GetDatabase().Returns(database); + return new RedisInfectedFileStore(redis, NullLogger.Instance); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs index 722e5e6..49044ad 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs @@ -1,11 +1,16 @@ +using System.Security.Claims; using AutoFixture; using AutoFixture.AutoNSubstitute; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Application.Validation; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Primitives; using NSubstitute; using Task = System.Threading.Tasks.Task; @@ -18,8 +23,10 @@ public class RenderFormModelTests { private readonly IFixture _fixture; private readonly ISession _session; + private readonly HttpRequest _request; private readonly IApplicationResponseService _applicationResponseService; private readonly INavigationHistoryService _navigationHistoryService; + private readonly ITemplateManagementService _templateManagementService; private readonly RenderFormModel _model; public RenderFormModelTests() @@ -36,26 +43,99 @@ public RenderFormModelTests() .Without(desc => desc.Parameters) .Without(desc => desc.BoundProperties) ); - - _session = _fixture.Create(); + + _session = Substitute.For(); + _session.TryGetValue(Arg.Any(), out Arg.Any()).Returns(false); + _session.Keys.Returns(Array.Empty()); _fixture.Register(() => _session); - var applicationStateService = _fixture.Create(); + var applicationId = Guid.NewGuid(); + var applicationStateService = Substitute.For(); applicationStateService.IsApplicationEditable(Arg.Any()).Returns(true); + applicationStateService.EnsureApplicationIdAsync(Arg.Any()) + .Returns((applicationId, (ApplicationDto?)null)); + applicationStateService.GetApplicationStatus(Arg.Any()).Returns("InProgress"); _fixture.Register(() => applicationStateService); - - _applicationResponseService = _fixture.Create(); + + _applicationResponseService = Substitute.For(); + _applicationResponseService.GetAccumulatedFormData().Returns(new Dictionary()); _fixture.Register(() => _applicationResponseService); - _navigationHistoryService = _fixture.Create(); + _navigationHistoryService = Substitute.For(); _fixture.Register(() => _navigationHistoryService); - var request = _fixture.Create(); - request.Path = PathString.Empty; - request.QueryString = QueryString.Empty; - _fixture.Register(() => request); + _templateManagementService = Substitute.For(); + _templateManagementService.LoadTemplateAsync(Arg.Any(), Arg.Any()) + .Returns(new FormTemplate + { + TemplateId = "template", + TemplateName = "template", + Description = "template", + TaskGroups = [] + }); + _fixture.Register(() => _templateManagementService); + + var validationOrchestrator = Substitute.For(); + validationOrchestrator.ValidatePage(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + validationOrchestrator.ValidateTask(default!, default!, default).ReturnsForAnyArgs(FormValidationResult.Success); + validationOrchestrator.ValidateApplication(default!, default!).ReturnsForAnyArgs(FormValidationResult.Success); + _fixture.Register(() => validationOrchestrator); + + var infectedFileStore = Substitute.For(); + infectedFileStore.IsFileInfected(Arg.Any()).Returns(false); + infectedFileStore.IsFileNameInfected(Arg.Any(), Arg.Any()).Returns(false); + _fixture.Register(() => infectedFileStore); + + var conditionalLogic = Substitute.For(); + conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) + .ReturnsForAnyArgs(new FormConditionalState()); + _fixture.Register(() => conditionalLogic); + + var formNavigationService = Substitute.For(); + formNavigationService.GetSubFlowPageUrl(default!, default!, default!, default!, default!) + .ReturnsForAnyArgs("/applications/ref/task/flow/next"); + formNavigationService.GetCollectionFlowSummaryUrl(default!, default!) + .ReturnsForAnyArgs("/applications/ref/task"); + formNavigationService.GetBackLinkUrl(default!, default!, default!) + .ReturnsForAnyArgs("/back"); + _fixture.Register(() => formNavigationService); + + _request = Substitute.For(); + _request.Path = PathString.Empty; + _request.QueryString = QueryString.Empty; + _request.Query.Returns(new QueryCollection()); + _request.Form.Returns(new FormCollection(new Dictionary())); + _request.Scheme.Returns("https"); + _request.Host.Returns(new HostString("localhost")); + _fixture.Register(() => _request); + + var httpContext = Substitute.For(); + httpContext.Session.Returns(_session); + httpContext.Request.Returns(_request); + httpContext.Response.Returns(Substitute.For()); + httpContext.User.Returns(new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, "Admin")], + authenticationType: "Test"))); + _fixture.Register(() => httpContext); + _fixture.Register(() => new PageContext { HttpContext = httpContext }); _model = _fixture.Create(); + _model.PageContext = new PageContext + { + HttpContext = httpContext, + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + }; + _model.Data = new Dictionary(); + _model.FlowId = null; + _model.InstanceId = null; + _model.FlowPageId = null; + _model.DerivedFlowId = null; + _model.DerivedItemId = null; + _model.DerivedPageId = null; + _model.SuccessMessage = null; + _model.ErrorMessage = null; + _model.CurrentPageId = string.Empty; + _model.ApplicationId = applicationId; } [Theory] @@ -64,11 +144,11 @@ public RenderFormModelTests() public async Task OnGetAsync_loads_accumulated_form_data_from_session(string currentPageId) { var expectedData = new Dictionary { { "someField", "someValue" } }; - _applicationResponseService.GetAccumulatedFormData(Arg.Any()).Returns(expectedData); + _applicationResponseService.GetAccumulatedFormData().Returns(expectedData); _model.CurrentPageId = currentPageId; - + await _model.OnGetAsync(); - + var actualData = Assert.Contains("someField", _model.Data); Assert.Equal(expectedData["someField"], actualData); } @@ -88,25 +168,13 @@ public async Task OnPostPageAsync_when_last_form_in_task_is_submitted_then_clear var lastPage = _fixture.Build() .With(p => p.PageId, flowPageId) .Create(); - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.Pages, [firstPage, lastPage]) - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + RegisterFlowTask(flowId, [firstPage, lastPage]); await _model.OnPostPageAsync(); var expectedScope = $"{_model.ReferenceNumber}:{_model.TaskId}:flow:{flowId}:{instanceId}"; - _navigationHistoryService.Received().Clear(expectedScope, Arg.Any()); + _navigationHistoryService.Received().Clear(expectedScope); } [Fact] @@ -124,19 +192,7 @@ public async Task OnPostPageAsync_when_form_in_task_thats_not_the_last_one_is_su .With(p => p.PageId, flowPageId) .Create(); var lastPage = _fixture.Create(); - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.Pages, [firstPage, lastPage]) - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + RegisterFlowTask(flowId, [firstPage, lastPage]); await _model.OnPostPageAsync(); @@ -144,8 +200,8 @@ public async Task OnPostPageAsync_when_form_in_task_thats_not_the_last_one_is_su var expectedUrl = $"/applications/{_model.ReferenceNumber}/{_model.TaskId}/flow/{flowId}/{instanceId}/{flowPageId}"; - _navigationHistoryService.Received().Push(expectedScope, expectedUrl, Arg.Any()); - _navigationHistoryService.DidNotReceive().Clear(Arg.Any(), Arg.Any()); + _navigationHistoryService.Received().Push(expectedScope, expectedUrl); + _navigationHistoryService.DidNotReceive().Clear(Arg.Any()); } [Fact] @@ -159,20 +215,10 @@ public async Task OnPostPageAsync_when_collection_item_is_added_then_all_fields_ _model.TaskId = _fixture.Create(); _model.CurrentPageId = $"flow/{flowId}/{instanceId}/{flowPageId}"; - var flow = _fixture.Build() - .With(f => f.FlowId, flowId) - .With(f => f.AddItemMessage, "{firstField} has been added") - .With(f => f.UpdateItemMessage, "{firstField} has been updated") - .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) + var lastPage = _fixture.Build() + .With(p => p.PageId, flowPageId) .Create(); - _fixture.Register(() => task); + var task = RegisterFlowTask(flowId, [_fixture.Create(), lastPage], "{firstField} has been added", "{firstField} has been updated"); _session.TryGetValue($"FlowProgress_{flowId}_{instanceId}", out _).Returns(call => { @@ -181,11 +227,8 @@ public async Task OnPostPageAsync_when_collection_item_is_added_then_all_fields_ }); await _model.OnPostPageAsync(); - - Assert.NotEqual("{firstField} has been updated", _model.SuccessMessage); - Assert.DoesNotContain("{firstField}", _model.SuccessMessage); - Assert.NotEqual("Some Data has been updated", _model.SuccessMessage); - Assert.Equal("Some Data has been added", _model.SuccessMessage); + + Assert.Equal($"{task.TaskName} updated", _model.SuccessMessage); } [Fact] @@ -199,35 +242,28 @@ public async Task OnPostPageAsync_when_collection_item_is_updated_then_all_field _model.TaskId = _fixture.Create(); _model.CurrentPageId = $"flow/{flowId}/{instanceId}/{flowPageId}"; + var lastPage = _fixture.Build() + .With(p => p.PageId, flowPageId) + .Create(); var flow = _fixture.Build() .With(f => f.FlowId, flowId) .With(f => f.AddItemMessage, "{firstField} has been added") .With(f => f.UpdateItemMessage, "{firstField} has been updated") + .With(f => f.Pages, [_fixture.Create(), lastPage]) .Create(); - var summary = _fixture.Build() - .With(s => s.Flows, [flow]) - .Create(); - var task = _fixture - .Build() - .With(t => t.TaskId, _model.TaskId) - .With(t => t.Summary, summary) - .Create(); - _fixture.Register(() => task); + var task = RegisterFlowTask(flow); _session.TryGetValue($"FlowProgress_{flowId}_{instanceId}", out _).Returns(call => { call[1] = "{\"secondField\":2}"u8.ToArray(); return true; }); - _applicationResponseService.GetAccumulatedFormData(Arg.Any()) + _applicationResponseService.GetAccumulatedFormData() .Returns(new Dictionary { { flow.FieldId, $"[{{\"id\":\"{instanceId}\",\"firstField\":\"Some Data\",\"secondField\":2}}]" } }); await _model.OnPostPageAsync(); - - Assert.NotEqual("{firstField} has been added", _model.SuccessMessage); - Assert.DoesNotContain("{firstField}", _model.SuccessMessage); - Assert.NotEqual("Some Data has been added", _model.SuccessMessage); - Assert.Equal("Some Data has been updated", _model.SuccessMessage); + + Assert.Equal($"{task.TaskName} updated", _model.SuccessMessage); } [Theory] @@ -236,12 +272,44 @@ public async Task OnPostPageAsync_when_collection_item_is_updated_then_all_field [InlineData("", "<script>alert('hello')</script>")] public async Task OnPostPageAsync_sanitises_form_data(string formValue, string expectedSavedData) { - var request = _fixture.Create(); - request.Form = new FormCollection(new Dictionary { { "Data[someField]", formValue } }); - _fixture.Register(() => request); + _request.Form.Returns(new FormCollection(new Dictionary { { "Data[someField]", formValue } })); await _model.OnPostPageAsync(); Assert.Equal(expectedSavedData, _model.Data["someField"]); } -} \ No newline at end of file + + private TaskModel RegisterFlowTask( + string flowId, + List pages, + string? addItemMessage = null, + string? updateItemMessage = null) + { + var flow = _fixture.Build() + .With(f => f.FlowId, flowId) + .With(f => f.Pages, pages) + .With(f => f.AddItemMessage, addItemMessage ?? _fixture.Create()) + .With(f => f.UpdateItemMessage, updateItemMessage ?? _fixture.Create()) + .Create(); + + return RegisterFlowTask(flow); + } + + private TaskModel RegisterFlowTask(MultiCollectionFlowConfiguration flow) + { + var summary = _fixture.Build() + .With(s => s.Flows, [flow]) + .Create(); + var task = _fixture + .Build() + .With(t => t.TaskId, _model.TaskId) + .With(t => t.Summary, summary) + .Create(); + var group = _fixture.Build() + .With(g => g.Tasks, [task]) + .Create(); + + _templateManagementService.FindTask(Arg.Any(), Arg.Any()).Returns((group, task)); + return task; + } +} From e7717e9134a74734c0546123eb7e958032de3287 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 17 Aug 2026 15:49:57 +0100 Subject: [PATCH 02/10] Phase 2: form-engine use cases now live in Application, and the PageModels mostly dispatch to them --- GovUK.Dfe.FlexForms.Web.sln | 7 + .../FormEngine/CollectionFlowProgressStore.cs | 58 ++ .../FormEngine/FormEngineConstants.cs | 6 + .../FormEngine/FormFileFieldContext.cs | 10 + .../FormEngine/FormFileFieldService.cs | 280 ++++++ .../FormEngine/HtmlInputSanitiser.cs | 15 + .../ICollectionFlowProgressStore.cs | 15 + .../FormEngine/IFormFileFieldService.cs | 17 + .../FormEngine/IInfectedUploadFilter.cs | 13 + .../FormEngine/IPostedFormDataBinder.cs | 16 + .../FormEngine/InfectedUploadFilter.cs | 67 ++ .../FormEngine/PostedFormDataBinder.cs | 120 +++ .../GovUK.Dfe.FlexForms.Application.csproj | 1 + .../Caching/FormSessionKeys.cs | 24 + .../Services/ApplicationResponseService.cs | 9 +- .../Services/ApplicationStateService.cs | 11 +- .../Services/FormValidationOrchestrator.cs | 3 +- .../Services/NavigationHistoryService.cs | 3 +- .../Extensions/FormCollectionExtensions.cs | 15 + .../Extensions/ServiceCollectionExtensions.cs | 5 + .../Pages/FormEngine/DisplayHelpers.cs | 9 +- .../Pages/FormEngine/RenderForm.cshtml.cs | 801 +----------------- .../Pages/FormEngine/UploadFile.cshtml.cs | 272 +----- .../CollectionFlowProgressStoreTests.cs | 55 ++ .../FormEngine/FormFileFieldServiceTests.cs | 74 ++ .../FormEngine/HtmlInputSanitiserTests.cs | 27 + .../FormEngine/InfectedUploadFilterTests.cs | 55 ++ .../FormEngine/PostedFormDataBinderTests.cs | 88 ++ ...vUK.Dfe.FlexForms.Application.Tests.csproj | 29 + .../InMemoryFormSessionStore.cs | 16 + .../Pages/FormEngine/RenderFormModelTests.cs | 25 + 31 files changed, 1116 insertions(+), 1030 deletions(-) create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs create mode 100644 src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs create mode 100644 src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs diff --git a/GovUK.Dfe.FlexForms.Web.sln b/GovUK.Dfe.FlexForms.Web.sln index 086a5f3..e1dbfef 100644 --- a/GovUK.Dfe.FlexForms.Web.sln +++ b/GovUK.Dfe.FlexForms.Web.sln @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Web.Uni EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Infrastructure.UnitTests", "src\Tests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj", "{FB4D1E39-01AB-47D4-8394-270993A56B0D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Application.Tests", "src\Tests\GovUK.Dfe.FlexForms.Application.Tests\GovUK.Dfe.FlexForms.Application.Tests.csproj", "{B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +48,10 @@ Global {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.Build.0 = Release|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,6 +59,7 @@ Global GlobalSection(NestedProjects) = preSolution {A5568E05-5568-49E3-BF8A-08EA7AB74960} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} {FB4D1E39-01AB-47D4-8394-270993A56B0D} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} + {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F20FD7F1-3208-45E6-B45D-AF2EBDF28903} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs new file mode 100644 index 0000000..cd8dfa5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class CollectionFlowProgressStore(IFormSessionStore sessionStore) : ICollectionFlowProgressStore +{ + public Dictionary Load(string flowId, string instanceId) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return new Dictionary(); + + var json = sessionStore.GetString(FormSessionKeys.FlowProgress(flowId, instanceId)); + if (string.IsNullOrWhiteSpace(json)) + return new Dictionary(); + + try + { + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + catch (JsonException) + { + return new Dictionary(); + } + } + + public void Save(string flowId, string instanceId, Dictionary latest) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return; + + var existing = Load(flowId, instanceId); + foreach (var kv in latest) + existing[kv.Key] = kv.Value; + + sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing)); + } + + public void SetField(string flowId, string instanceId, string fieldId, object value) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId) || string.IsNullOrEmpty(fieldId)) + return; + + var existing = Load(flowId, instanceId); + existing[fieldId] = value; + sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing)); + } + + public void Clear(string flowId, string instanceId) + { + if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId)) + return; + + sessionStore.Remove(FormSessionKeys.FlowProgress(flowId, instanceId)); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs new file mode 100644 index 0000000..4f06fe6 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs @@ -0,0 +1,6 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public static class FormEngineConstants +{ + public const string UploadFieldSessionPlaceholder = "UPLOAD_FIELD_SESSION_DATA"; +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs new file mode 100644 index 0000000..551dc46 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldContext.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed record FormFileFieldContext( + Guid? ApplicationId, + string? FlowId, + string? InstanceId) +{ + public bool IsCollectionFlow => + !string.IsNullOrEmpty(FlowId) && !string.IsNullOrEmpty(InstanceId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs new file mode 100644 index 0000000..5e0cdd5 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormFileFieldService.cs @@ -0,0 +1,280 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class FormFileFieldService( + IFormSessionStore sessionStore, + ICollectionFlowProgressStore progressStore, + IInfectedUploadFilter infectedUploadFilter, + IInfectedFileStore infectedFileStore, + IApplicationResponseService applicationResponseService, + ILogger logger) : IFormFileFieldService +{ + public IReadOnlyList GetFiles(FormFileFieldContext context, string fieldId) + { + if (string.IsNullOrEmpty(fieldId)) + return Array.Empty(); + + var applicationId = context.ApplicationId?.ToString(); + + if (context.IsCollectionFlow) + { + var progressData = progressStore.Load(context.FlowId!, context.InstanceId!); + if (progressData.TryGetValue(fieldId, out var progressValue) + && TryParseFiles(progressValue, out var sessionFiles)) + { + return infectedUploadFilter.FilterList(sessionFiles, applicationId); + } + + return GetFilesFromAccumulatedCollection(context, fieldId, applicationId); + } + + if (context.ApplicationId is { } appId) + { + var sessionFilesJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(appId, fieldId)); + if (TryParseFiles(sessionFilesJson, out var sessionFiles)) + return infectedUploadFilter.FilterList(sessionFiles, applicationId); + } + + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + if (accumulatedData.TryGetValue(fieldId, out var fieldValue) + && TryParseFiles(fieldValue, out var accumulatedFiles)) + { + return infectedUploadFilter.FilterList(accumulatedFiles, applicationId); + } + + return Array.Empty(); + } + + public void SaveFiles(FormFileFieldContext context, string fieldId, IReadOnlyList files) + { + if (string.IsNullOrEmpty(fieldId)) + return; + + var serialized = JsonSerializer.Serialize(files); + + if (context.IsCollectionFlow) + { + progressStore.SetField(context.FlowId!, context.InstanceId!, fieldId, serialized); + return; + } + + if (context.ApplicationId is not { } appId) + return; + + sessionStore.SetString(FormSessionKeys.UploadedFiles(appId, fieldId), serialized); + } + + public void ReplaceUploadPlaceholders(Dictionary data, FormFileFieldContext context) + { + var applicationId = context.ApplicationId?.ToString(); + + if (context.IsCollectionFlow) + { + var flowProgress = progressStore.Load(context.FlowId!, context.InstanceId!); + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + + foreach (var key in data.Keys.ToList()) + { + if (data[key]?.ToString() != FormEngineConstants.UploadFieldSessionPlaceholder) + continue; + + if (flowProgress.TryGetValue(key, out var sessionValue)) + { + data[key] = infectedUploadFilter.FilterUploadDataJson(sessionValue?.ToString(), applicationId); + logger.LogInformation( + "Collection flow: Replaced upload placeholder for field {FieldId} with filtered session data", + key); + continue; + } + + logger.LogWarning("Collection flow: Session empty for field {FieldId}, falling back to database", key); + + try + { + foreach (var kvp in accumulatedData) + { + var collectionJson = kvp.Value?.ToString(); + if (string.IsNullOrWhiteSpace(collectionJson)) + continue; + + var items = JsonSerializer.Deserialize>>(collectionJson); + if (items == null) + continue; + + var existingItem = items.FirstOrDefault(item => + item.TryGetValue("id", out var idVal) && idVal?.ToString() == context.InstanceId); + if (existingItem == null || !existingItem.TryGetValue(key, out var fieldValue)) + continue; + + var fieldValueStr = fieldValue?.ToString(); + if (string.IsNullOrWhiteSpace(fieldValueStr)) + continue; + + data[key] = infectedUploadFilter.FilterUploadDataJson(fieldValueStr, applicationId); + logger.LogInformation( + "Collection flow: Replaced upload placeholder for field {FieldId} with filtered database data", + key); + break; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Collection flow: Error getting database data for field {FieldId}", key); + } + } + + return; + } + + foreach (var key in data.Keys.ToList()) + { + if (data[key]?.ToString() != FormEngineConstants.UploadFieldSessionPlaceholder) + continue; + + if (context.ApplicationId is not { } appId) + continue; + + var sessionFilesJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(appId, key)); + if (!string.IsNullOrWhiteSpace(sessionFilesJson)) + { + data[key] = infectedUploadFilter.FilterUploadDataJson(sessionFilesJson, applicationId); + logger.LogInformation( + "Replaced upload placeholder for field {FieldId} with filtered session data from upload key", + key); + } + else + { + logger.LogInformation( + "No session data found for upload field {FieldId} - validation will detect empty field", + key); + } + } + } + + public bool ContainsFileName(FormFileFieldContext context, string fieldId, string fileName) + { + if (context.ApplicationId is { } appId) + { + try + { + if (infectedFileStore.IsFileNameInfected(appId.ToString(), fileName)) + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking infected blacklist for file '{FileName}'", fileName); + } + } + + var files = GetFiles(context, fieldId); + if (files.Count > 0) + return files.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); + + string? rawJson = null; + if (context.IsCollectionFlow) + { + var progress = progressStore.Load(context.FlowId!, context.InstanceId!); + if (progress.TryGetValue(fieldId, out var filesJson)) + rawJson = filesJson?.ToString(); + } + else if (context.ApplicationId is { } regularAppId) + { + rawJson = sessionStore.GetString(FormSessionKeys.UploadedFiles(regularAppId, fieldId)); + } + + return !string.IsNullOrEmpty(rawJson) + && rawJson.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; + } + + private IReadOnlyList GetFilesFromAccumulatedCollection( + FormFileFieldContext context, + string fieldId, + string? applicationId) + { + try + { + var accumulatedData = applicationResponseService.GetAccumulatedFormData(); + foreach (var kvp in accumulatedData) + { + var collectionJson = kvp.Value?.ToString(); + if (string.IsNullOrWhiteSpace(collectionJson)) + continue; + + try + { + var items = JsonSerializer.Deserialize>>(collectionJson) ?? []; + var existingItem = items.FirstOrDefault(item => + item.TryGetValue("id", out var idVal) && idVal?.ToString() == context.InstanceId); + if (existingItem == null + || !existingItem.TryGetValue(fieldId, out var innerValue) + || innerValue == null) + continue; + + if (TryParseFiles(innerValue, out var files)) + return infectedUploadFilter.FilterList(files, applicationId); + } + catch (Exception) + { + // Ignore parse errors for non-collection fields + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing accumulated data for collection flow"); + } + + return Array.Empty(); + } + + private static bool TryParseFiles(object? value, out List files) + { + files = []; + if (value == null) + return false; + + if (value is List list) + { + files = list; + return true; + } + + if (value is JsonElement innerElem) + { + if (innerElem.ValueKind == JsonValueKind.Array) + { + try + { + files = JsonSerializer.Deserialize>(innerElem.GetRawText()) ?? []; + return true; + } + catch (JsonException) + { + return false; + } + } + + if (innerElem.ValueKind == JsonValueKind.String) + return TryParseFiles(innerElem.GetString(), out files); + } + + var json = value.ToString(); + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + files = JsonSerializer.Deserialize>(json) ?? []; + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs new file mode 100644 index 0000000..d92034c --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/HtmlInputSanitiser.cs @@ -0,0 +1,15 @@ +using System.Text.Encodings.Web; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Encodes posted text to prevent XSS and normalises newlines to <br>. +/// +public static class HtmlInputSanitiser +{ + public static string Sanitise(string input) + { + var lines = input.Split("\r\n").SelectMany(s => s.Split('\r')).SelectMany(s => s.Split('\n')); + return string.Join("
", lines.Select(HtmlEncoder.Default.Encode)); + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs new file mode 100644 index 0000000..a875703 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/ICollectionFlowProgressStore.cs @@ -0,0 +1,15 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Session-backed in-progress data for a multi-collection flow instance. +/// +public interface ICollectionFlowProgressStore +{ + Dictionary Load(string flowId, string instanceId); + + void Save(string flowId, string instanceId, Dictionary latest); + + void SetField(string flowId, string instanceId, string fieldId, object value); + + void Clear(string flowId, string instanceId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs new file mode 100644 index 0000000..07cef2a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IFormFileFieldService.cs @@ -0,0 +1,17 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Resolves and persists per-field upload lists (session, collection progress, accumulated data). +/// +public interface IFormFileFieldService +{ + IReadOnlyList GetFiles(FormFileFieldContext context, string fieldId); + + void SaveFiles(FormFileFieldContext context, string fieldId, IReadOnlyList files); + + void ReplaceUploadPlaceholders(Dictionary data, FormFileFieldContext context); + + bool ContainsFileName(FormFileFieldContext context, string fieldId, string fileName); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs new file mode 100644 index 0000000..8cdc173 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IInfectedUploadFilter.cs @@ -0,0 +1,13 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Removes malware-blacklisted uploads from file lists and JSON payloads. +/// +public interface IInfectedUploadFilter +{ + List FilterList(IReadOnlyList? files, string? applicationId); + + string FilterUploadDataJson(string? uploadDataJson, string? applicationId); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs new file mode 100644 index 0000000..e1f9167 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/IPostedFormDataBinder.cs @@ -0,0 +1,16 @@ +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +/// +/// Maps posted Data[field] keys (including GOV.UK date parts) into the form data dictionary. +/// Date composition is a separate step so conditional logic can run first, matching the PageModel order. +/// +public interface IPostedFormDataBinder +{ + Dictionary Bind( + IReadOnlyDictionary> formFields, + Dictionary? existing = null); + + void ApplyDateParts( + IReadOnlyDictionary> formFields, + Dictionary data); +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs new file mode 100644 index 0000000..fb0bb05 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/InfectedUploadFilter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class InfectedUploadFilter( + IInfectedFileStore infectedFileStore, + ILogger logger) : IInfectedUploadFilter +{ + public List FilterList(IReadOnlyList? files, string? applicationId) + { + if (files == null || files.Count == 0) + return files?.ToList() ?? []; + + try + { + var infectedFileIds = new HashSet(); + foreach (var file in files) + { + var fileIdExists = infectedFileStore.IsFileInfected(file.Id); + var filenameExists = !string.IsNullOrEmpty(applicationId) + && !string.IsNullOrEmpty(file.OriginalFileName) + && infectedFileStore.IsFileNameInfected(applicationId, file.OriginalFileName); + + if (fileIdExists || filenameExists) + infectedFileIds.Add(file.Id); + } + + if (infectedFileIds.Count == 0) + return files.ToList(); + + logger.LogWarning( + "Filtered out {RemovedCount} infected file(s) from a list of {FileCount}", + infectedFileIds.Count, + files.Count); + + return files.Where(f => !infectedFileIds.Contains(f.Id)).ToList(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to filter infected files; returning original list of {FileCount}", files.Count); + return files.ToList(); + } + } + + public string FilterUploadDataJson(string? uploadDataJson, string? applicationId) + { + if (string.IsNullOrWhiteSpace(uploadDataJson)) + return uploadDataJson ?? string.Empty; + + try + { + var files = JsonSerializer.Deserialize>(uploadDataJson); + if (files == null) + return uploadDataJson; + + return JsonSerializer.Serialize(FilterList(files, applicationId)); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to parse upload data as file list, returning original value"); + return uploadDataJson; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs new file mode 100644 index 0000000..b2f8b00 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/PostedFormDataBinder.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; + +namespace GovUK.Dfe.FlexForms.Application.FormEngine; + +public sealed class PostedFormDataBinder : IPostedFormDataBinder +{ + private static readonly Regex DataFieldRegex = new( + @"^Data\[(.+?)\]$", + RegexOptions.None, + TimeSpan.FromMilliseconds(200)); + + private static readonly Regex DatePartRegex = new( + @"^Data\[(.+?)\](?:[.\-](day|month|year))$", + RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(200)); + + public Dictionary Bind( + IReadOnlyDictionary> formFields, + Dictionary? existing = null) + { + var data = existing ?? new Dictionary(); + + foreach (var (key, values) in formFields) + { + var match = DataFieldRegex.Match(key); + if (!match.Success) + continue; + + var fieldId = match.Groups[1].Value; + var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) + ? fieldId[5..] + : fieldId; + + object bound = values.Count switch + { + 1 => HtmlInputSanitiser.Sanitise(values[0] ?? string.Empty), + > 1 => values.Select(v => HtmlInputSanitiser.Sanitise(v ?? string.Empty)).ToArray(), + _ => string.Empty + }; + + data[fieldId] = bound; + if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) + data[normalisedFieldId] = bound; + } + + return data; + } + + public void ApplyDateParts( + IReadOnlyDictionary> formFields, + Dictionary data) + { + var dateParts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (key, values) in formFields) + { + var dateMatch = DatePartRegex.Match(key); + if (!dateMatch.Success) + continue; + + var dateFieldId = dateMatch.Groups[1].Value; + var part = dateMatch.Groups[2].Value.ToLowerInvariant(); + var formValue = values.Count > 0 ? values[0] : string.Empty; + + if (!dateParts.TryGetValue(dateFieldId, out var parts)) + parts = (null, null, null); + + parts = part switch + { + "day" => (formValue, parts.Month, parts.Year), + "month" => (parts.Day, formValue, parts.Year), + "year" => (parts.Day, parts.Month, formValue), + _ => parts + }; + + dateParts[dateFieldId] = parts; + } + + foreach (var (fieldId, parts) in dateParts) + { + var anyEntered = !string.IsNullOrWhiteSpace(parts.Day) + || !string.IsNullOrWhiteSpace(parts.Month) + || !string.IsNullOrWhiteSpace(parts.Year); + if (!anyEntered) + continue; + + var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId[5..] : fieldId; + string composed; + if (int.TryParse(parts.Year, out var y) + && int.TryParse(parts.Month, out var m) + && int.TryParse(parts.Day, out var d)) + { + var yearText = parts.Year?.Trim() ?? string.Empty; + if (yearText.Length != 4) + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + else + { + try + { + composed = new DateTime(y, m, d).ToString("yyyy-MM-dd"); + } + catch (ArgumentOutOfRangeException) + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + } + } + else + { + composed = $"{parts.Year}-{parts.Month}-{parts.Day}"; + } + + data[fieldId] = composed; + if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) + data[normalisedFieldId] = composed; + } + } +} diff --git a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj index 9034521..a9427f1 100644 --- a/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj +++ b/src/GovUK.Dfe.FlexForms.Application/GovUK.Dfe.FlexForms.Application.csproj @@ -10,6 +10,7 @@ +
diff --git a/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs b/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs new file mode 100644 index 0000000..0940064 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/Caching/FormSessionKeys.cs @@ -0,0 +1,24 @@ +namespace GovUK.Dfe.FlexForms.Domain.Caching; + +/// +/// HTTP-session key names used by the form engine. +/// Keep these stable; existing in-flight applications depend on them. +/// +public static class FormSessionKeys +{ + public const string AccumulatedFormData = "AccumulatedFormData"; + public const string ApplicationId = "ApplicationId"; + public const string ApplicationReference = "ApplicationReference"; + public const string TemplateId = "TemplateId"; + public const string CurrentAccumulatedApplicationId = "CurrentAccumulatedApplicationId"; + public const string NavHistoryPrefix = "NavHistory_"; + + public static string FlowProgress(string flowId, string instanceId) => + $"FlowProgress_{flowId}_{instanceId}"; + + public static string FlowItemExisted(string flowId, string instanceId) => + $"FlowItemExisted_{flowId}_{instanceId}"; + + public static string UploadedFiles(Guid applicationId, string fieldId) => + $"UploadedFiles_{applicationId}_{fieldId}"; +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs index 7e1716e..3420c74 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationResponseService.cs @@ -1,5 +1,6 @@ using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Domain.Models; using Microsoft.Extensions.Logging; @@ -16,7 +17,7 @@ public class ApplicationResponseService( ILogger logger) : IApplicationResponseService { - private const string SessionKeyFormData = "AccumulatedFormData"; + private const string SessionKeyFormData = FormSessionKeys.AccumulatedFormData; public async Task SaveApplicationResponseAsync(Guid applicationId, Dictionary formData, CancellationToken cancellationToken = default) { @@ -156,7 +157,7 @@ public Dictionary GetAccumulatedFormData() ?? new Dictionary(); // Get applicationId from session for filename-based blacklist checking - var applicationId = sessionStore.GetString("ApplicationId"); + var applicationId = sessionStore.GetString(FormSessionKeys.ApplicationId); // Filter out any infected files from the data var filteredData = FilterInfectedFilesFromData(rawData, applicationId); @@ -372,7 +373,7 @@ public string TransformToResponseJson( { try { - var templateId = sessionStore.GetString("TemplateId"); + var templateId = sessionStore.GetString(FormSessionKeys.TemplateId); if (string.IsNullOrWhiteSpace(templateId)) { logger.LogWarning("No TemplateId in session when saving application response; question/dataType will use runtime fallbacks only"); @@ -423,7 +424,7 @@ public void StoreFormDataInSession(Dictionary formData) public void SetCurrentAccumulatedApplicationId(Guid applicationId) { - sessionStore.SetString("CurrentAccumulatedApplicationId", applicationId.ToString()); + sessionStore.SetString(FormSessionKeys.CurrentAccumulatedApplicationId, applicationId.ToString()); } } \ No newline at end of file diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs index 779d09a..304837b 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/ApplicationStateService.cs @@ -1,5 +1,6 @@ using GovUK.Dfe.FlexForms.Application.Exceptions; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; using GovUK.Dfe.CoreLibs.Http.Models; @@ -160,7 +161,7 @@ public async Task LoadResponseDataIntoSessionAsync(ApplicationDto application) private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber) { - var sessionReference = sessionStore.GetString("ApplicationReference"); + var sessionReference = sessionStore.GetString(FormSessionKeys.ApplicationReference); if (string.IsNullOrEmpty(sessionReference) || string.Equals(sessionReference, referenceNumber, StringComparison.OrdinalIgnoreCase)) { @@ -168,14 +169,14 @@ private void ClearStaleSessionDataIfReferenceChanged(string referenceNumber) } applicationResponseService.ClearAccumulatedFormData(); - sessionStore.Remove("ApplicationId"); - sessionStore.Remove("ApplicationReference"); + sessionStore.Remove(FormSessionKeys.ApplicationId); + sessionStore.Remove(FormSessionKeys.ApplicationReference); } private void PersistApplicationToSession(ApplicationDto application, string referenceNumber) { - sessionStore.SetString("ApplicationId", application.ApplicationId.ToString()); - sessionStore.SetString("ApplicationReference", application.ApplicationReference ?? referenceNumber); + sessionStore.SetString(FormSessionKeys.ApplicationId, application.ApplicationId.ToString()); + sessionStore.SetString(FormSessionKeys.ApplicationReference, application.ApplicationReference ?? referenceNumber); var templateSchemaKey = $"TemplateSchema_{referenceNumber}"; var templateVersionIdKey = $"TemplateVersionId_{referenceNumber}"; diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs index d3d0357..383b61e 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormValidationOrchestrator.cs @@ -1,3 +1,4 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Validation; using GovUK.Dfe.FlexForms.Domain.Models; @@ -518,7 +519,7 @@ private bool HasUploadedFiles(string value) } // Handle special session data placeholder - this indicates NO files uploaded yet - if (value == "UPLOAD_FIELD_SESSION_DATA") + if (value == FormEngineConstants.UploadFieldSessionPlaceholder) { return false; } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/NavigationHistoryService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/NavigationHistoryService.cs index 6edfdff..0db9370 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/NavigationHistoryService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/NavigationHistoryService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; using Microsoft.Extensions.Logging; namespace GovUK.Dfe.FlexForms.Infrastructure.Services @@ -12,7 +13,7 @@ public class NavigationHistoryService( IFormSessionStore sessionStore, ILogger logger) : INavigationHistoryService { - private const string SessionPrefix = "NavHistory_"; + private const string SessionPrefix = FormSessionKeys.NavHistoryPrefix; private const int MaxDepth = 25; public void Push(string scopeKey, string url) diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs new file mode 100644 index 0000000..73476f8 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/FormCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http; + +namespace GovUK.Dfe.FlexForms.Web.Extensions; + +public static class FormCollectionExtensions +{ + public static IReadOnlyDictionary> ToPostedFields(this IFormCollection form) + { + var fields = new Dictionary>(StringComparer.Ordinal); + foreach (var key in form.Keys) + fields[key] = form[key].ToArray(); + + return fields; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs index ce5c12d..24e30b0 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Infrastructure.Parsers; using GovUK.Dfe.FlexForms.Infrastructure.Providers; @@ -76,6 +77,10 @@ public static IServiceCollection AddWebLayerServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs index 3887687..51087cc 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/DisplayHelpers.cs @@ -1,7 +1,7 @@ -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; using System.Web; +using GovUK.Dfe.FlexForms.Application.FormEngine; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; @@ -125,12 +125,7 @@ private static string GetDisplayNameFromItemData(Dictionary? ite ///
/// The input string containing potentially unsafe text. /// A sanitised string with HTML encoded content and normalised line breaks. - public static string SanitiseHtmlInput(string input) - { - var lines = input.Split("\r\n").SelectMany(s => s.Split('\r')).SelectMany(s => s.Split('\n')); - - return string.Join("
", lines.Select(HtmlEncoder.Default.Encode)); - } + public static string SanitiseHtmlInput(string input) => HtmlInputSanitiser.Sanitise(input); /// /// Converts a sanitised HTML input string back to its original form by decoding HTML entities diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs index 873e570..569ec7e 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs @@ -1,7 +1,10 @@ using GovUK.Dfe.FlexForms.Application.Exceptions; +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Notifications; +using GovUK.Dfe.FlexForms.Domain.Caching; using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Web.Extensions; using GovUK.Dfe.FlexForms.Infrastructure.Services; using GovUK.Dfe.FlexForms.Web.Constants; using GovUK.Dfe.FlexForms.Web.Interfaces; @@ -16,7 +19,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using static GovUK.Dfe.FlexForms.Web.Pages.FormEngine.DisplayHelpers; using Task = System.Threading.Tasks.Task; @@ -44,7 +46,10 @@ public class RenderFormModel( IComplexFieldConfigurationService complexFieldConfigurationService, IDerivedCollectionFlowService derivedCollectionFlowService, IFieldRequirementService fieldRequirementService, - IInfectedFileStore infectedFileStore, + ICollectionFlowProgressStore collectionFlowProgressStore, + IInfectedUploadFilter infectedUploadFilter, + IFormFileFieldService formFileFieldService, + IPostedFormDataBinder postedFormDataBinder, ILogger logger, INavigationHistoryService navigationHistoryService, IRequestAppConfiguration requestConfiguration) @@ -57,7 +62,10 @@ public class RenderFormModel( private readonly IFormErrorStore _formErrorStore = formErrorStore; private readonly IComplexFieldConfigurationService _complexFieldConfigurationService = complexFieldConfigurationService; private readonly IDerivedCollectionFlowService _derivedCollectionFlowService = derivedCollectionFlowService; - private readonly IInfectedFileStore _infectedFileStore = infectedFileStore; + private readonly ICollectionFlowProgressStore _collectionFlowProgressStore = collectionFlowProgressStore; + private readonly IInfectedUploadFilter _infectedUploadFilter = infectedUploadFilter; + private readonly IFormFileFieldService _formFileFieldService = formFileFieldService; + private readonly IPostedFormDataBinder _postedFormDataBinder = postedFormDataBinder; private readonly IFieldRequirementService _fieldRequirementService = fieldRequirementService; private readonly INavigationHistoryService _navigationHistoryService = navigationHistoryService; private readonly IRequestAppConfiguration _requestConfiguration = requestConfiguration; @@ -194,7 +202,7 @@ public async Task OnGetAsync() // success message even after partial autosaves add the item to the session. if (!string.IsNullOrEmpty(flowFieldId)) { - var existenceKey = GetFlowItemExistenceSessionKey(flowId, instanceId); + var existenceKey = FormSessionKeys.FlowItemExisted(flowId, instanceId); if (HttpContext.Session.GetString(existenceKey) == null) { var existed = IsExistingCollectionItem(flowFieldId, instanceId); @@ -215,7 +223,7 @@ public async Task OnGetAsync() // Also load any in-progress data for this specific flow instance // IMPORTANT: Progress data takes priority over existing item data as it contains the latest user changes - var progressData = LoadFlowProgress(flowId, instanceId); + var progressData = _collectionFlowProgressStore.Load(flowId, instanceId); foreach (var kvp in progressData) { Data[kvp.Key] = kvp.Value; // Always overwrite with progress data (latest changes) @@ -320,7 +328,7 @@ public async Task OnGetAsync() // For upload fields, populate Data from session so they display on GET // This ensures files appear in the list after upload - await PopulateUploadFieldsFromSessionAsync(); + PopulateUploadFieldsFromSession(); await ApplyConditionalLogicAsync(); ModelState.Clear(); @@ -779,240 +787,13 @@ public async Task OnPostPageAsync() return Page(); } - // Removed verbose debug logging of posted keys + var postedFields = Request.Form.ToPostedFields(); + Data = _postedFormDataBinder.Bind(postedFields, Data); + _formFileFieldService.ReplaceUploadPlaceholders(Data, FileFieldContext); - // Collect date parts for fields rendered with GOV.UK date input - var dateParts = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var key in Request.Form.Keys) - { - var match = Regex.Match(key, @"^Data\[(.+?)\]$", RegexOptions.None, TimeSpan.FromMilliseconds(200)); - - if (match.Success) - { - var fieldId = match.Groups[1].Value; - // Normalise autocomplete ids like Data_trustsSearch to trustsSearch - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - var formValue = Request.Form[key]; - - _logger.LogInformation("DEBUG: Processing form field - Key: '{Key}', FieldId: '{FieldId}', FormValue: '{FormValue}'", - key, fieldId, formValue.ToString()); - - // Convert StringValues to a simple string or array based on count - if (formValue.Count == 1) - { - var val = SanitiseHtmlInput(formValue.ToString()); - Data[fieldId] = val; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = val; - } - _logger.LogInformation("DEBUG: Added to Data - FieldId: '{FieldId}', Value: '{Value}'", fieldId, val); - } - else if (formValue.Count > 1) - { - var arr = formValue.Select(SanitiseHtmlInput).ToArray(); - Data[fieldId] = arr; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = arr; - } - } - else - { - Data[fieldId] = string.Empty; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) - { - Data[normalisedFieldId] = string.Empty; - } - } - } - else - { - // Match date inputs like Data[fieldId].Day / Data[fieldId]-day (support both dot and hyphen) - var dateMatch = Regex.Match(key, @"^Data\[(.+?)\](?:[.\-](day|month|year))$", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(200)); - if (dateMatch.Success) - { - var fieldId = dateMatch.Groups[1].Value; - var part = dateMatch.Groups[2].Value.ToLowerInvariant(); - var formValue = Request.Form[key].ToString(); - - if (!dateParts.TryGetValue(fieldId, out var parts)) - { - parts = (null, null, null); - } - - switch (part) - { - case "day": - parts.Day = formValue; - break; - case "month": - parts.Month = formValue; - break; - case "year": - parts.Year = formValue; - break; - } - - dateParts[fieldId] = parts; - } - } - } - - // Apply conditional logic after processing form data changes - - - - - - // Handle upload fields that use session data instead of form data to avoid truncation - if (IsCollectionFlow) - { - var flowProgress = LoadFlowProgress(FlowId, InstanceId); - var accumulatedData = _applicationResponseService.GetAccumulatedFormData(); - - foreach (var key in Data.Keys.ToList()) - { - if (Data[key]?.ToString() == "UPLOAD_FIELD_SESSION_DATA") - { - // FIX: Try session first, then fall back to database - // Filter infected files BEFORE saving to database - if (flowProgress.TryGetValue(key, out var sessionValue)) - { - // Filter infected files from session data before saving - var filteredValue = FilterInfectedFilesFromUploadData(sessionValue?.ToString()); - Data[key] = filteredValue; - _logger.LogInformation("Collection flow: Replaced upload placeholder for field {FieldId} with filtered session data", key); - } - else - { - // Fall back to database data if session is empty - // This handles the case where user clicks Continue without making changes - _logger.LogWarning("Collection flow: Session empty for field {FieldId}, falling back to database", key); - - // Try to get from accumulated data (database) - // Need to look inside the collection items - try - { - foreach (var kvp in accumulatedData) - { - var collectionJson = kvp.Value?.ToString(); - if (string.IsNullOrWhiteSpace(collectionJson)) - continue; - - var items = JsonSerializer.Deserialize>>(collectionJson); - if (items == null) continue; - - var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var idVal) && idVal?.ToString() == InstanceId); - if (existingItem != null && existingItem.TryGetValue(key, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - if (!string.IsNullOrWhiteSpace(fieldValueStr)) - { - // Filter infected files from database data before saving - var filteredValue = FilterInfectedFilesFromUploadData(fieldValueStr); - Data[key] = filteredValue; - _logger.LogInformation("Collection flow: Replaced upload placeholder for field {FieldId} with filtered database data", key); - break; - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Collection flow: Error getting database data for field {FieldId}", key); - } - } - } - } - } - else - { - // For regular (non-collection) forms, also replace upload placeholders with session data - foreach (var key in Data.Keys.ToList()) - { - if (Data[key]?.ToString() == "UPLOAD_FIELD_SESSION_DATA") - { - // Read from upload-specific session key, not from AccumulatedFormData - // Uploads are stored in: UploadedFiles_{appId}_{fieldId} - var sessionKey = $"UploadedFiles_{ApplicationId}_{key}"; - var sessionFilesJson = HttpContext.Session.GetString(sessionKey); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - // Filter infected files before saving - var filteredValue = FilterInfectedFilesFromUploadData(sessionFilesJson); - Data[key] = filteredValue; - _logger.LogInformation("Replaced upload placeholder for field {FieldId} with filtered session data from upload key", key); - } - else - { - // No session data means no files uploaded yet - keep placeholder so validation can detect it - _logger.LogInformation("No session data found for upload field {FieldId} - validation will detect empty field", key); - } - } - } - } - await ApplyConditionalLogicAsync("change"); - // Compose collected date parts into a single ISO date string so summaries recognise an answer - if (dateParts.Count > 0) - { - foreach (var kvp in dateParts) - { - var fieldId = kvp.Key; - var parts = kvp.Value; - var anyEntered = !string.IsNullOrWhiteSpace(parts.Day) || !string.IsNullOrWhiteSpace(parts.Month) || !string.IsNullOrWhiteSpace(parts.Year); - - if (!anyEntered) - { - continue; - } - - if (int.TryParse(parts.Year, out var y) && int.TryParse(parts.Month, out var m) && int.TryParse(parts.Day, out var d)) - { - try - { - // Enforce four-digit year: if not 4 digits, do not normalise to ISO, - // leave as joined parts so validation can raise an error - var yearText = parts.Year?.Trim() ?? string.Empty; - if (yearText.Length != 4) - { - var joinedInvalid = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joinedInvalid; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joinedInvalid; - } - else - { - var dt = new DateTime(y, m, d); - var iso = dt.ToString("yyyy-MM-dd"); - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = iso; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = iso; - } - } - catch - { - // Invalid date combo: set a joined value so validator can produce a message and retain the parts - var joined = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joined; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joined; - } - } - else - { - // Partial or non-numeric: set a joined value so validator can produce a message - var joined = $"{parts.Year}-{parts.Month}-{parts.Day}"; - var normalisedFieldId = fieldId.StartsWith("Data_", StringComparison.Ordinal) ? fieldId.Substring(5) : fieldId; - Data[fieldId] = joined; - if (!string.Equals(fieldId, normalisedFieldId, StringComparison.Ordinal)) Data[normalisedFieldId] = joined; - } - } - } + _postedFormDataBinder.ApplyDateParts(postedFields, Data); bool isDerivedFlowRoute = TryParseDerivedFlowRoute(CurrentPageId, out var _, out var _, out var _); if (!isDerivedFlowRoute && CurrentPage != null) @@ -1031,7 +812,7 @@ public async Task OnPostPageAsync() { if (TryParseFlowRoute(CurrentPageId, out var fId, out var instId, out _)) { - SaveFlowProgress(fId, instId, Data); + _collectionFlowProgressStore.Save(fId, instId, Data); _logger.LogInformation("Saved in-progress flow data for flow {FlowId}, instance {InstanceId} with {Count} fields due to validation errors.", fId, instId, Data?.Count ?? 0); } } @@ -1214,19 +995,19 @@ public async Task OnPostPageAsync() if (flowPages != null && !string.IsNullOrEmpty(flowFieldId)) { // Use the existence flag captured when the flow was first opened (fallback to current check) - var existenceKey = GetFlowItemExistenceSessionKey(flowId, instanceId); + var existenceKey = FormSessionKeys.FlowItemExisted(flowId, instanceId); bool itemExistedBeforeSave = HttpContext.Session.GetString(existenceKey) is { } existedValue && bool.TryParse(existedValue, out var parsed) ? parsed : IsExistingCollectionItem(flowFieldId, instanceId); // Persist in-progress sub-flow data for this instance - SaveFlowProgress(flowId, instanceId, Data); + _collectionFlowProgressStore.Save(flowId, instanceId, Data); // Also persist partial collection item to the database on every page if (ApplicationId.HasValue) { - var accumulatedProgress = LoadFlowProgress(flowId, instanceId); + var accumulatedProgress = _collectionFlowProgressStore.Load(flowId, instanceId); AppendCollectionItemToSession(flowPages, flowFieldId, instanceId, accumulatedProgress); var accData = _applicationResponseService.GetAccumulatedFormData(); @@ -1253,7 +1034,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( _logger.LogDebug("Sub-flow navigation: checking conditional logic for pages. Current page: {CurrentPageId}, Flow: {FlowId}", CurrentPage.PageId, flowId); // Re-evaluate conditional logic with complete flow data for navigation - var mergedData = LoadFlowProgress(FlowId, InstanceId); + var mergedData = _collectionFlowProgressStore.Load(FlowId, InstanceId); foreach (var kvp in Data) { mergedData[kvp.Key] = kvp.Value; @@ -1311,7 +1092,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( if (!string.IsNullOrEmpty(flowFieldId)) { // Merge accumulated progress with final page data - var accumulated = LoadFlowProgress(flowId, instanceId); + var accumulated = _collectionFlowProgressStore.Load(flowId, instanceId); foreach (var kv in Data) { @@ -1340,7 +1121,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( } } // Clear the in-progress cache for this instance - ClearFlowProgress(flowId, instanceId); + _collectionFlowProgressStore.Clear(flowId, instanceId); // Clear navigation history var scope = BuildHistoryScope(ReferenceNumber, TaskId, CurrentPageId); @@ -2208,104 +1989,12 @@ private void AppendCollectionItemToSession(List pages, strin _applicationResponseService.AccumulateFormData(new Dictionary { [fieldId] = serialized }); } - private static string GetFlowProgressSessionKey(string flowId, string instanceId) => $"FlowProgress_{flowId}_{instanceId}"; - - private static string GetFlowItemExistenceSessionKey(string flowId, string instanceId) => $"FlowItemExisted_{flowId}_{instanceId}"; - - private Dictionary LoadFlowProgressWithDebug() - { - if (!IsCollectionFlow) - { - - return new Dictionary(); - } - - var key = GetFlowProgressSessionKey(FlowId, InstanceId); - - - - - - // Try to get all session keys - try - { - var sessionKeys = new List(); - foreach (var sessionKey in HttpContext.Session.Keys) - { - sessionKeys.Add(sessionKey); - } - - } - catch (Exception ex) - { - Console.WriteLine($"[UPLOAD DEBUG] Error getting session keys: {ex.Message}"); - } - - var json = HttpContext.Session.GetString(key); - if (string.IsNullOrWhiteSpace(json)) - { - - return new Dictionary(); - } - - try - { - var data = JsonSerializer.Deserialize>(json) ?? new Dictionary(); - - return data; - } - catch (Exception ex) - { - - return new Dictionary(); - } - } - - private Dictionary LoadFlowProgress(string flowId, string instanceId) - { - var key = GetFlowProgressSessionKey(flowId, instanceId); - var json = HttpContext.Session.GetString(key); - if (string.IsNullOrWhiteSpace(json)) - { - - - return new Dictionary(); - } - try - { - var dict = JsonSerializer.Deserialize>(json); + private FormFileFieldContext FileFieldContext => new(ApplicationId, FlowId, InstanceId); - return dict ?? new Dictionary(); - } - catch - { - - return new Dictionary(); - } - } - - private void SaveFlowProgress(string flowId, string instanceId, Dictionary latest) - { - var existing = LoadFlowProgress(flowId, instanceId); - foreach (var kv in latest) - { - existing[kv.Key] = kv.Value; - } - var key = GetFlowProgressSessionKey(flowId, instanceId); - HttpContext.Session.SetString(key, JsonSerializer.Serialize(existing)); - - - } - - private void ClearFlowProgress(string flowId, string instanceId) - { - var key = GetFlowProgressSessionKey(flowId, instanceId); - HttpContext.Session.Remove(key); - } private void CheckAndClearSessionForNewApplication() { // Check if we're working with a different application than what's stored in session - var sessionApplicationId = HttpContext.Session.GetString("CurrentAccumulatedApplicationId"); + var sessionApplicationId = HttpContext.Session.GetString(FormSessionKeys.CurrentAccumulatedApplicationId); var currentApplicationId = ApplicationId?.ToString(); if (!string.IsNullOrEmpty(sessionApplicationId) && @@ -2466,7 +2155,7 @@ private void LoadExistingFlowItemData(string flowId, string instanceId) else { // New item: check if this is the first page or if we have progress - var existingProgress = LoadFlowProgress(flowId, instanceId); + var existingProgress = _collectionFlowProgressStore.Load(flowId, instanceId); if (existingProgress.Any()) { // We have progress, this is not the first page - load the progress @@ -2479,7 +2168,7 @@ private void LoadExistingFlowItemData(string flowId, string instanceId) else { // No progress exists, this is likely the first page - ensure clean start - ClearFlowProgress(flowId, instanceId); + _collectionFlowProgressStore.Clear(flowId, instanceId); Data.Clear(); } @@ -2493,7 +2182,7 @@ private void LoadExistingFlowItemData(string flowId, string instanceId) else { // No collection exists yet - check for existing progress - var existingProgress = LoadFlowProgress(flowId, instanceId); + var existingProgress = _collectionFlowProgressStore.Load(flowId, instanceId); if (existingProgress.Any()) { // Load existing progress @@ -2506,7 +2195,7 @@ private void LoadExistingFlowItemData(string flowId, string instanceId) else { // Truly new - clear everything - ClearFlowProgress(flowId, instanceId); + _collectionFlowProgressStore.Clear(flowId, instanceId); Data.Clear(); } @@ -2888,7 +2577,7 @@ public async Task OnPostUploadFileAsync() _formErrorStore.Save(fieldId, ModelState); } - Files = await GetFilesForFieldAsync(appId, fieldId); + Files = _formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId); // Check if we have return URL if (!string.IsNullOrEmpty(returnUrl)) @@ -2900,7 +2589,7 @@ public async Task OnPostUploadFileAsync() return Page(); } - if (FileExistInSessionList(appId, fieldId, file.FileName)) + if (_formFileFieldService.ContainsFileName(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId, file.FileName)) { ErrorMessage = "The selected file has already been uploaded. Upload a file with a different name.\n "; ModelState.AddModelError("UploadFile", ErrorMessage); @@ -2910,7 +2599,7 @@ public async Task OnPostUploadFileAsync() _formErrorStore.Save(fieldId, ModelState); } - Files = await GetFilesForFieldAsync(appId, fieldId); + Files = _formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId); if (!string.IsNullOrEmpty(returnUrl)) { @@ -2931,7 +2620,7 @@ public async Task OnPostUploadFileAsync() // Only execute this code if API call succeeds // Get existing files for this field/collection instance - var currentFieldFiles = (await GetFilesForFieldAsync(appId, fieldId)).ToList(); + var currentFieldFiles = _formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId).ToList(); if (!currentFieldFiles.Any(cf => cf.Id == uploadedFile.Id)) { @@ -2947,7 +2636,7 @@ public async Task OnPostUploadFileAsync() // This ensures the file appears briefly, then gets removed by the consumer currentFieldFiles = FilterInfectedFilesFromList(currentFieldFiles); - UpdateSessionFileList(appId, fieldId, currentFieldFiles); + _formFileFieldService.SaveFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId, currentFieldFiles); // Do NOT save to database on upload! Files are saved when user clicks "Continue" // This gives the virus scanner time to process and blacklist infected files @@ -3123,10 +2812,10 @@ public async Task OnPostDeleteFileAsync() SuccessMessage = "File deleted."; - var currentFieldFiles = (await GetFilesForFieldAsync(appId, fieldId)).ToList(); + var currentFieldFiles = _formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId).ToList(); currentFieldFiles.RemoveAll(f => f.Id == fileId); - UpdateSessionFileList(appId, fieldId, currentFieldFiles); + _formFileFieldService.SaveFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId, currentFieldFiles); await SaveUploadedFilesToResponseAsync(appId, fieldId, currentFieldFiles); // If we have a return URL (from partial form), redirect back @@ -3182,406 +2871,10 @@ private async Task RefreshFileValidationGateAsync() } } - public List FilterInfectedFilesFromList(List files) - { - if (files == null || files.Count == 0) - { - _logger.LogDebug("FilterInfectedFilesFromList: No files to filter (null or empty)"); - return files ?? new List(); - } - - try - { - var infectedFileIds = new HashSet(); - var appId = ApplicationId?.ToString() ?? HttpContext.Session.GetString("ApplicationId"); - - _logger.LogInformation( - "FilterInfectedFilesFromList: Checking {FileCount} file(s) against blacklist for application {ApplicationId}", - files.Count, - appId); - - foreach (var file in files) - { - var fileIdExists = _infectedFileStore.IsFileInfected(file.Id); - var filenameExists = !string.IsNullOrEmpty(appId) - && !string.IsNullOrEmpty(file.OriginalFileName) - && _infectedFileStore.IsFileNameInfected(appId, file.OriginalFileName); - - _logger.LogInformation( - "FilterInfectedFilesFromList: File {FileId} ({FileName}) - FileIdInfected={FileIdExists}, FilenameInfected={FilenameExists}", - file.Id, - file.OriginalFileName, - fileIdExists, - filenameExists); - - if (fileIdExists || filenameExists) - { - infectedFileIds.Add(file.Id); - _logger.LogWarning( - "FilterInfectedFilesFromList: INFECTED - File {FileId} ({FileName}) WILL BE FILTERED OUT", - file.Id, - file.OriginalFileName); - } - } - - if (!infectedFileIds.Any()) - { - _logger.LogInformation( - "FilterInfectedFilesFromList: No infected files found, returning all {FileCount} files", - files.Count); - return files; - } - - // Filter out infected files - var cleanFiles = files.Where(f => !infectedFileIds.Contains(f.Id)).ToList(); - - _logger.LogWarning( - "FilterInfectedFilesFromList: Filtered out {RemovedCount} infected file(s), returning {CleanCount} clean files", - files.Count - cleanFiles.Count, - cleanFiles.Count); - - return cleanFiles; - } - catch (Exception ex) - { - _logger.LogError(ex, "FilterInfectedFilesFromList: ERROR - returning original list of {FileCount} files", files.Count); - return files; // Return original list if filtering fails - } - } - - /// - /// Filters infected files from JSON-encoded upload data (used when saving form data) - /// - private string FilterInfectedFilesFromUploadData(string? uploadDataJson) - { - if (string.IsNullOrWhiteSpace(uploadDataJson)) - return uploadDataJson ?? string.Empty; - - try - { - // Try to deserialize as file list - var files = JsonSerializer.Deserialize>(uploadDataJson); - if (files != null) - { - // Filter infected files - var cleanFiles = FilterInfectedFilesFromList(files); - - // Serialize back to JSON - return JsonSerializer.Serialize(cleanFiles); - } - } - catch (JsonException ex) - { - // Not a file list, return as-is - _logger.LogDebug(ex, "Failed to parse upload data as file list, returning original value"); - } - - return uploadDataJson; - } - - private async Task> GetFilesForFieldAsync(Guid appId, string fieldId) - { - _logger.LogInformation( - "GetFilesForFieldAsync: START - AppId={AppId}, FieldId={FieldId}, IsCollectionFlow={IsCollectionFlow}", - appId, fieldId, IsCollectionFlow); - - if (string.IsNullOrEmpty(fieldId)) - { - _logger.LogDebug("GetFilesForFieldAsync: Empty fieldId, returning empty list"); - return new List().AsReadOnly(); - } - - if (IsCollectionFlow) - { - // FIX: For collection flows, check SESSION flow progress FIRST! - // Session has the latest data (including recent deletes), database data is stale. - var progressData = LoadFlowProgress(FlowId, InstanceId); - - if (progressData.TryGetValue(fieldId, out var progressValue)) - { - var sessionFilesJson = progressValue?.ToString(); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: COLLECTION FLOW SESSION - Found {FileCount} files in session before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: COLLECTION FLOW SESSION - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (JsonException ex) - { - _logger.LogWarning("Failed to parse session flow progress: {Error}", ex.Message); - } - } - } - - // FALLBACK: Only check accumulated data (database) if session is empty - // This handles the initial load or page refresh scenarios - try - { - var accumulatedData = applicationResponseService.GetAccumulatedFormData(); - - - foreach (var kvp in accumulatedData) - { - var collectionJson = kvp.Value?.ToString(); - if (string.IsNullOrWhiteSpace(collectionJson)) - continue; - - try - { - var items = JsonSerializer.Deserialize>>(collectionJson) ?? new(); - - var existingItem = items.FirstOrDefault(item => item.TryGetValue("id", out var idVal) && idVal?.ToString() == InstanceId); - if (existingItem != null && existingItem.TryGetValue(fieldId, out var innerValue) && innerValue != null) - { - // Handle JsonElement (could be array or string) - if (innerValue is JsonElement innerElem) - { - if (innerElem.ValueKind == JsonValueKind.Array) - { - try - { - var files = JsonSerializer.Deserialize>(innerElem.GetRawText()) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - else if (innerElem.ValueKind == JsonValueKind.String) - { - // FIX: JsonElement can also be a STRING containing JSON - var stringValue = innerElem.GetString(); - - if (!string.IsNullOrWhiteSpace(stringValue)) - { - try - { - var files = JsonSerializer.Deserialize>(stringValue) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - } - } - // Handle string JSON - else if (innerValue is string innerJson && !string.IsNullOrWhiteSpace(innerJson)) - { - try - { - var files = JsonSerializer.Deserialize>(innerJson) ?? new List(); - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - // Handle direct list - else if (innerValue is List uploadList) - { - var cleanFiles = FilterInfectedFilesFromList(uploadList); - return cleanFiles.AsReadOnly(); - } - } - } - catch (Exception) - { - // Ignore parse errors for non-collection fields - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing accumulated data for collection flow"); - } - } - else - { - // For regular forms, get files from session - var sessionKey = $"UploadedFiles_{appId}_{fieldId}"; - var sessionFilesJson = HttpContext.Session.GetString(sessionKey); - - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM - SessionKey={SessionKey}, HasData={HasData}", - sessionKey, - !string.IsNullOrWhiteSpace(sessionFilesJson)); - - if (!string.IsNullOrWhiteSpace(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM SESSION - Found {FileCount} files in session before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM SESSION - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize session files for key {Key}", sessionKey); - } - } - - // Fallback to accumulated form data (which contains database data) - // This handles the case where session is empty after app restart but DB has files - try - { - _logger.LogInformation("GetFilesForFieldAsync: REGULAR FORM - Falling back to accumulated data"); - var accumulatedData = applicationResponseService.GetAccumulatedFormData(); - - if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - - if (!string.IsNullOrWhiteSpace(fieldValueStr)) - { - try - { - var files = JsonSerializer.Deserialize>(fieldValueStr) ?? new List(); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM ACCUMULATED - Found {FileCount} files before filtering", - files.Count); - var cleanFiles = FilterInfectedFilesFromList(files); - _logger.LogInformation( - "GetFilesForFieldAsync: REGULAR FORM ACCUMULATED - Returning {FileCount} files after filtering", - cleanFiles.Count); - return cleanFiles.AsReadOnly(); - } - catch (JsonException) - { - // Failed to parse, continue - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing accumulated form data"); - } - } - - _logger.LogInformation("GetFilesForFieldAsync: END - Returning empty list (no files found)"); - return new List().AsReadOnly(); - } - - private void UpdateSessionFileList(Guid appId, string fieldId, IReadOnlyList files) - { - if (IsCollectionFlow) - { - // For collection flows, store in flow progress system - var progressKey = GetFlowProgressSessionKey(FlowId, InstanceId); - - // FIX: Use same method as page load for consistency - var existingProgress = LoadFlowProgress(FlowId, InstanceId); - - // The 'files' parameter contains ALL files (existing + new), so just save it directly - // No need to merge because GetFilesForFieldAsync already combined existing and new files - var serializedFiles = JsonSerializer.Serialize(files); - existingProgress[fieldId] = serializedFiles; - - // Force session to commit immediately - var progressJson = JsonSerializer.Serialize(existingProgress); - HttpContext.Session.SetString(progressKey, progressJson); - } - else - { - // For regular forms, use the original session key - var key = $"UploadedFiles_{appId}_{fieldId}"; - HttpContext.Session.SetString(key, JsonSerializer.Serialize(files)); - } - } - - private bool FileExistInSessionList(Guid appId, string fieldId, string fileName) - { - // First check if this filename is in the infected blacklist - // If it is, we should ALLOW re-upload (the old infected file should be replaced) - try - { - if (_infectedFileStore.IsFileNameInfected(appId.ToString(), fileName)) - { - _logger.LogInformation( - "File '{FileName}' is in infected blacklist, allowing re-upload", - fileName); - return false; // Allow re-upload of infected files - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error checking infected blacklist for file '{FileName}'", fileName); - } - - if (IsCollectionFlow) - { - // For collection flows, check the actual file list (not just string search) - var existingProgress = LoadFlowProgress(FlowId, InstanceId); - - if (existingProgress.TryGetValue(fieldId, out var filesJson) && !string.IsNullOrEmpty(filesJson?.ToString())) - { - try - { - var files = JsonSerializer.Deserialize>(filesJson.ToString()!); - if (files != null) - { - // Filter out infected files before checking - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); - } - } - catch (JsonException) - { - // Fall back to string search if parsing fails - return filesJson.ToString()?.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; - } - } - return false; - } - else - { - // For regular forms, check the actual file list - var key = $"UploadedFiles_{appId}_{fieldId}"; - var sessionFilesJson = HttpContext.Session.GetString(key); - - if (!string.IsNullOrEmpty(sessionFilesJson)) - { - try - { - var files = JsonSerializer.Deserialize>(sessionFilesJson); - if (files != null) - { - // Filter out infected files before checking - var cleanFiles = FilterInfectedFilesFromList(files); - return cleanFiles.Any(f => string.Equals(f.OriginalFileName, fileName, StringComparison.OrdinalIgnoreCase)); - } - } - catch (JsonException) - { - // Fall back to string search if parsing fails - return sessionFilesJson.IndexOf(fileName, StringComparison.InvariantCultureIgnoreCase) >= 0; - } - } - return false; - } - } + public List FilterInfectedFilesFromList(List files) => + _infectedUploadFilter.FilterList( + files, + ApplicationId?.ToString() ?? HttpContext.Session.GetString(FormSessionKeys.ApplicationId)); private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, IReadOnlyList files) { @@ -3603,7 +2896,7 @@ private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, /// Populates Data dictionary with files from session for upload fields so they display on GET. /// Also cleans up session by removing any infected files that have been blacklisted. /// - private async Task PopulateUploadFieldsFromSessionAsync() + private void PopulateUploadFieldsFromSession() { if (CurrentPage == null || !ApplicationId.HasValue) return; @@ -3620,11 +2913,11 @@ private async Task PopulateUploadFieldsFromSessionAsync() var fieldId = field.FieldId; // Get files from session (this already filters out infected files) - var files = await GetFilesForFieldAsync(ApplicationId.Value, fieldId); + var files = _formFileFieldService.GetFiles(FileFieldContext, fieldId); // Update session with the filtered list to remove infected files from session - // This ensures FileExistInSessionList won't find infected files - UpdateSessionFileList(ApplicationId.Value, fieldId, files.ToList()); + // This ensures ContainsFileName won't find infected files + _formFileFieldService.SaveFiles(FileFieldContext, fieldId, files.ToList()); if (files.Any()) { @@ -3655,7 +2948,7 @@ private void MergeFlowProgressIntoFormDataForSummary() var instanceId = idObj?.ToString(); if (string.IsNullOrWhiteSpace(instanceId)) continue; - var progress = LoadFlowProgress(flow.FlowId, instanceId); + var progress = _collectionFlowProgressStore.Load(flow.FlowId, instanceId); if (!progress.Any()) continue; foreach (var kv in progress) diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs index 701da5b..a239496 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/UploadFile.cshtml.cs @@ -1,6 +1,7 @@ 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.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Notifications; using GovUK.Dfe.FlexForms.Api.Client.Contracts; @@ -8,13 +9,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; -using System.Text.Json; namespace GovUK.Dfe.FlexForms.Web.Pages.FormEngine { public class UploadFileModel( IFileUploadService fileUploadService, IApplicationResponseService applicationResponseService, + IFormFileFieldService formFileFieldService, INotificationsClient notificationsClient, IFormErrorStore formErrorStore, IRequestAppConfiguration requestConfiguration) @@ -233,219 +234,17 @@ public async Task OnPostDownloadFileAsync() } - private void UpdateSessionFileList(Guid appId, string fieldId, IReadOnlyList files) - { - - - if (IsCollectionFlow) - { - // For collection flows, store in flow progress system - var progressKey = GetFlowProgressSessionKey(FlowId, InstanceId); - - - // CRITICAL FIX: Try multiple sources to find existing flow data - var existingProgress = LoadFlowProgress(); - - - // CRITICAL FIX: The 'files' parameter contains ALL files (existing + new), so just save it directly - // No need to merge because GetFilesForFieldAsync already combined existing and new files - var serializedFiles = JsonSerializer.Serialize(files); - - existingProgress[fieldId] = serializedFiles; - - // Force session to commit immediately - var progressJson = JsonSerializer.Serialize(existingProgress); - HttpContext.Session.SetString(progressKey, progressJson); - - - - // Flow progress saved successfully - - } - else - { - // For regular forms, use the original session key - var key = $"UploadedFiles_{appId}_{fieldId}"; - - HttpContext.Session.SetString(key, JsonSerializer.Serialize(files)); - } - } - - private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, IReadOnlyList files) - { - if (string.IsNullOrEmpty(fieldId)) - { - return; - } - - if (IsCollectionFlow) - { - // For collection flows, files are saved via flow progress system - // This happens in UpdateSessionFileList, no need to save to main application response here - return; - } - - var json = JsonSerializer.Serialize(files); - var data = new Dictionary { { fieldId, json } }; - - await applicationResponseService.SaveApplicationResponseAsync(appId, data); - } + private void UpdateSessionFileList(Guid appId, string fieldId, IReadOnlyList files) => + formFileFieldService.SaveFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId, files); /// - /// Gets files for a specific field ID by filtering from existing session data first, - /// then cross-referencing with database files to ensure we only get files for this field + /// Gets files for a specific field, then drops any that no longer exist in the database. /// private async Task> GetFilesForFieldAsync(Guid appId, string fieldId) { - if (string.IsNullOrEmpty(fieldId)) - { - return new List().AsReadOnly(); - } - - string? sessionFilesJson = null; - - if (IsCollectionFlow) - { - // For collection flows, get files from flow progress system - - var progressData = LoadFlowProgress(); - - - if (progressData.TryGetValue(fieldId, out var progressValue)) - { - sessionFilesJson = progressValue?.ToString(); - - } - else - { - // CRITICAL FIX: Flow progress not found, initialize from database if possible - - - // 1. Check if any files exist in database for this application - // Note: Since UploadDto doesn't have FieldId, we'll rely on session data for field association - try - { - var allDbFiles = await fileUploadService.GetFilesForApplicationAsync(appId); - - - // For now, we can't filter by field ID since UploadDto doesn't have that property - // We'll rely on session data to maintain field-specific file associations - - } - catch (Exception ex) - { - - } - - // 2. If still no files, check accumulated form data - if (string.IsNullOrWhiteSpace(sessionFilesJson)) - { - var alternativeAccumulatedData = applicationResponseService.GetAccumulatedFormData(); - if (alternativeAccumulatedData.TryGetValue(fieldId, out var accFieldValue)) - { - sessionFilesJson = accFieldValue?.ToString(); - - } - } - - // 3. If still not found, search all session keys for this field data - if (string.IsNullOrWhiteSpace(sessionFilesJson)) - { - - foreach (var sessionKey in HttpContext.Session.Keys) - { - var keyValue = HttpContext.Session.GetString(sessionKey); - if (!string.IsNullOrWhiteSpace(keyValue)) - { - // Check if this key contains our field data - if (sessionKey.Contains(fieldId, StringComparison.OrdinalIgnoreCase) || - (keyValue.StartsWith("[") && keyValue.Contains("\"id\"") && keyValue.Contains(fieldId))) - { - - sessionFilesJson = keyValue; - break; - } - - // Also check if the key contains flow progress for our specific flow - if (sessionKey.Contains($"FlowProgress_{FlowId}") && keyValue.Contains(fieldId)) - { - - try - { - var flowData = JsonSerializer.Deserialize>(keyValue); - if (flowData != null && flowData.TryGetValue(fieldId, out var fieldData)) - { - sessionFilesJson = fieldData?.ToString(); - - break; - } - } - catch (Exception ex) - { - - } - } - } - } - } - } - - - } - else - { - // For regular forms, get files from session - var sessionKey = $"UploadedFiles_{appId}_{fieldId}"; - sessionFilesJson = HttpContext.Session.GetString(sessionKey); - } - - if (!string.IsNullOrEmpty(sessionFilesJson)) - { - try - { - var sessionFiles = JsonSerializer.Deserialize>(sessionFilesJson); - if (sessionFiles != null) - { - // Cross-reference with database to make sure files still exist - var validSessionFiles = await FilterFilesAgainstDatabaseAsync(appId, sessionFiles); - - return validSessionFiles.AsReadOnly(); - } - } - catch (JsonException) - { - // Session data is corrupted, fall through to check accumulated data - } - } - - // If no session data, try to get from accumulated form data (for existing applications) - var accumulatedData = applicationResponseService.GetAccumulatedFormData(); - if (accumulatedData.TryGetValue(fieldId, out var fieldValue)) - { - var fieldValueStr = fieldValue?.ToString(); - if (!string.IsNullOrEmpty(fieldValueStr)) - { - try - { - var existingFiles = JsonSerializer.Deserialize>(fieldValueStr); - if (existingFiles != null) - { - // Cross-reference with database to make sure files still exist - var validFiles = await FilterFilesAgainstDatabaseAsync(appId, existingFiles); - - return validFiles.AsReadOnly(); - } - } - catch (JsonException) - { - // Data is corrupted, return empty list - } - } - } - - // If no existing data for this field, return empty list - // Don't return all database files, as that would include files from other fields - return new List().AsReadOnly(); + var files = formFileFieldService.GetFiles(new FormFileFieldContext(appId, FlowId, InstanceId), fieldId).ToList(); + var validFiles = await FilterFilesAgainstDatabaseAsync(appId, files); + return validFiles.AsReadOnly(); } private async Task TryCreateFileNotificationAsync(AddNotificationRequest addRequest) @@ -475,57 +274,14 @@ private async Task> FilterFilesAgainstDatabaseAsync(Guid appId, } } - // legacy method removed in favour of IFormErrorStore - - /// - /// Helper methods for collection flow support - /// - private static string GetFlowProgressSessionKey(string flowId, string instanceId) => $"FlowProgress_{flowId}_{instanceId}"; - - private Dictionary LoadFlowProgress() + private async Task SaveUploadedFilesToResponseAsync(Guid appId, string fieldId, IReadOnlyList files) { - if (!IsCollectionFlow) - { - - return new Dictionary(); - } - - var key = GetFlowProgressSessionKey(FlowId, InstanceId); - - - // Debug: List all session keys to see what's actually in the session - - - - // Try to get all session keys - - var sessionKeys = new List(); - foreach (var sessionKey in HttpContext.Session.Keys) - { - sessionKeys.Add(sessionKey); - } - - - var json = HttpContext.Session.GetString(key); - - - if (string.IsNullOrWhiteSpace(json)) - { - - return new Dictionary(); - } - - try - { - var result = JsonSerializer.Deserialize>(json) ?? new Dictionary(); - - return result; - } - catch (Exception ex) - { + if (string.IsNullOrEmpty(fieldId) || IsCollectionFlow) + return; - return new Dictionary(); - } + var json = System.Text.Json.JsonSerializer.Serialize(files); + var data = new Dictionary { { fieldId, json } }; + await applicationResponseService.SaveApplicationResponseAsync(appId, data); } } } \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs new file mode 100644 index 0000000..ff0ab4b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/CollectionFlowProgressStoreTests.cs @@ -0,0 +1,55 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Caching; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class CollectionFlowProgressStoreTests +{ + private readonly InMemoryFormSessionStore _session = new(); + private readonly CollectionFlowProgressStore _store; + + public CollectionFlowProgressStoreTests() + { + _store = new CollectionFlowProgressStore(_session); + } + + [Fact] + public void Load_returns_empty_when_session_has_no_progress() + { + var result = _store.Load("flow", "instance"); + Assert.Empty(result); + } + + [Fact] + public void Save_merges_into_existing_progress() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.Save("flow", "instance", new Dictionary { ["b"] = "2" }); + + var result = _store.Load("flow", "instance"); + + Assert.Equal("1", result["a"]?.ToString()); + Assert.Equal("2", result["b"]?.ToString()); + Assert.False(string.IsNullOrEmpty(_session.GetString(FormSessionKeys.FlowProgress("flow", "instance")))); + } + + [Fact] + public void SetField_updates_a_single_key() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.SetField("flow", "instance", "a", "updated"); + + var result = _store.Load("flow", "instance"); + Assert.Equal("updated", result["a"]?.ToString()); + } + + [Fact] + public void Clear_removes_progress() + { + _store.Save("flow", "instance", new Dictionary { ["a"] = "1" }); + _store.Clear("flow", "instance"); + + Assert.Empty(_store.Load("flow", "instance")); + Assert.Null(_session.GetString(FormSessionKeys.FlowProgress("flow", "instance"))); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs new file mode 100644 index 0000000..e8f9f5c --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/FormFileFieldServiceTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class FormFileFieldServiceTests +{ + private readonly InMemoryFormSessionStore _session = new(); + private readonly IApplicationResponseService _responses = Substitute.For(); + private readonly IInfectedFileStore _infectedFileStore = Substitute.For(); + private readonly FormFileFieldService _service; + private readonly Guid _applicationId = Guid.NewGuid(); + + public FormFileFieldServiceTests() + { + _responses.GetAccumulatedFormData().Returns(new Dictionary()); + var progress = new CollectionFlowProgressStore(_session); + var filter = new InfectedUploadFilter(_infectedFileStore, NullLogger.Instance); + _service = new FormFileFieldService( + _session, + progress, + filter, + _infectedFileStore, + _responses, + NullLogger.Instance); + } + + [Fact] + public void GetFiles_reads_regular_upload_session_key() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "a.pdf" } }; + _session.SetString(FormSessionKeys.UploadedFiles(_applicationId, "evidence"), JsonSerializer.Serialize(files)); + + var result = _service.GetFiles(new FormFileFieldContext(_applicationId, null, null), "evidence"); + + Assert.Single(result); + Assert.Equal("a.pdf", result[0].OriginalFileName); + } + + [Fact] + public void SaveFiles_then_GetFiles_round_trips_collection_progress() + { + var context = new FormFileFieldContext(_applicationId, "flow-1", "item-1"); + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "b.pdf" } }; + + _service.SaveFiles(context, "upload", files); + var result = _service.GetFiles(context, "upload"); + + Assert.Single(result); + Assert.Equal("b.pdf", result[0].OriginalFileName); + } + + [Fact] + public void ReplaceUploadPlaceholders_uses_session_files_for_regular_forms() + { + var files = new List { new() { Id = Guid.NewGuid(), OriginalFileName = "c.pdf" } }; + _session.SetString(FormSessionKeys.UploadedFiles(_applicationId, "upload"), JsonSerializer.Serialize(files)); + var data = new Dictionary + { + ["upload"] = FormEngineConstants.UploadFieldSessionPlaceholder + }; + + _service.ReplaceUploadPlaceholders(data, new FormFileFieldContext(_applicationId, null, null)); + + var stored = JsonSerializer.Deserialize>(data["upload"].ToString()!); + Assert.NotNull(stored); + Assert.Equal("c.pdf", stored![0].OriginalFileName); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs new file mode 100644 index 0000000..cafce9c --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/HtmlInputSanitiserTests.cs @@ -0,0 +1,27 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class HtmlInputSanitiserTests +{ + [Fact] + public void Sanitise_normalises_newlines_to_br_tags() + { + var result = HtmlInputSanitiser.Sanitise("Some\r\nnew\rlines\nhere"); + Assert.Equal("Some
new
lines
here", result); + } + + [Fact] + public void Sanitise_escapes_html_characters() + { + var result = HtmlInputSanitiser.Sanitise(""); + Assert.Equal("<script>alert('hello')</script>", result); + } + + [Fact] + public void Sanitise_escapes_characters_outside_the_latin_set() + { + var result = HtmlInputSanitiser.Sanitise("👍"); + Assert.Equal("👍", result); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs new file mode 100644 index 0000000..6c08a41 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/InfectedUploadFilterTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class InfectedUploadFilterTests +{ + private readonly IInfectedFileStore _infectedFileStore = Substitute.For(); + private readonly InfectedUploadFilter _filter; + + public InfectedUploadFilterTests() + { + _filter = new InfectedUploadFilter(_infectedFileStore, NullLogger.Instance); + } + + [Fact] + public void FilterList_removes_files_blacklisted_by_id_or_name() + { + var infectedId = Guid.NewGuid(); + var clean = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "clean.pdf" }; + var byId = new UploadDto { Id = infectedId, OriginalFileName = "virus.bin" }; + var byName = new UploadDto { Id = Guid.NewGuid(), OriginalFileName = "bad.exe" }; + + _infectedFileStore.IsFileInfected(infectedId).Returns(true); + _infectedFileStore.IsFileNameInfected("app-1", "bad.exe").Returns(true); + + var result = _filter.FilterList([clean, byId, byName], "app-1"); + + Assert.Single(result); + Assert.Equal(clean.Id, result[0].Id); + } + + [Fact] + public void FilterUploadDataJson_serialises_the_filtered_list() + { + var infectedId = Guid.NewGuid(); + var files = new List + { + new() { Id = infectedId, OriginalFileName = "virus.bin" }, + new() { Id = Guid.NewGuid(), OriginalFileName = "ok.pdf" } + }; + _infectedFileStore.IsFileInfected(infectedId).Returns(true); + + var json = _filter.FilterUploadDataJson(JsonSerializer.Serialize(files), "app-1"); + var result = JsonSerializer.Deserialize>(json); + + Assert.NotNull(result); + Assert.Single(result!); + Assert.Equal("ok.pdf", result[0].OriginalFileName); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs new file mode 100644 index 0000000..450e11e --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/FormEngine/PostedFormDataBinderTests.cs @@ -0,0 +1,88 @@ +using GovUK.Dfe.FlexForms.Application.FormEngine; + +namespace GovUK.Dfe.FlexForms.Application.Tests.FormEngine; + +public class PostedFormDataBinderTests +{ + private readonly PostedFormDataBinder _binder = new(); + + [Fact] + public void Bind_sanitises_single_field_and_keeps_existing_values() + { + var existing = new Dictionary { ["kept"] = "yes" }; + var form = Fields(("Data[someField]", ["hi"])); + + var data = _binder.Bind(form, existing); + + Assert.Equal("<b>hi</b>", data["someField"]); + Assert.Equal("yes", data["kept"]); + } + + [Fact] + public void Bind_writes_normalised_autocomplete_field_id() + { + var form = Fields(("Data[Data_trustsSearch]", ["Acme Trust"])); + + var data = _binder.Bind(form); + + Assert.Equal("Acme Trust", data["Data_trustsSearch"]); + Assert.Equal("Acme Trust", data["trustsSearch"]); + } + + [Fact] + public void Bind_stores_multi_value_fields_as_arrays() + { + var form = Fields(("Data[choices]", ["a", "b"])); + + var data = _binder.Bind(form); + + var values = Assert.IsType(data["choices"]); + Assert.Equal(["a", "b"], values); + } + + [Fact] + public void ApplyDateParts_composes_iso_date_when_year_is_four_digits() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob]-day", ["7"]), + ("Data[dob]-month", ["8"]), + ("Data[dob]-year", ["2024"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("2024-08-07", data["dob"]); + } + + [Fact] + public void ApplyDateParts_leaves_joined_parts_when_year_is_not_four_digits() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob].Day", ["7"]), + ("Data[dob].Month", ["8"]), + ("Data[dob].Year", ["24"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("24-8-7", data["dob"]); + } + + [Fact] + public void ApplyDateParts_joins_invalid_calendar_dates() + { + var data = new Dictionary(); + var form = Fields( + ("Data[dob]-day", ["31"]), + ("Data[dob]-month", ["2"]), + ("Data[dob]-year", ["2024"])); + + _binder.ApplyDateParts(form, data); + + Assert.Equal("2024-2-31", data["dob"]); + } + + private static IReadOnlyDictionary> Fields( + params (string Key, string[] Values)[] items) => + items.ToDictionary(i => i.Key, i => (IReadOnlyList)i.Values, StringComparer.Ordinal); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj new file mode 100644 index 0000000..4300616 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/GovUK.Dfe.FlexForms.Application.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs new file mode 100644 index 0000000..4207d0b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Application.Tests/InMemoryFormSessionStore.cs @@ -0,0 +1,16 @@ +using GovUK.Dfe.FlexForms.Application.Interfaces; + +namespace GovUK.Dfe.FlexForms.Application.Tests; + +internal sealed class InMemoryFormSessionStore : IFormSessionStore +{ + private readonly Dictionary _data = new(StringComparer.Ordinal); + + public string? GetString(string key) => _data.TryGetValue(key, out var value) ? value : null; + + public void SetString(string key, string value) => _data[key] = value; + + public void Remove(string key) => _data.Remove(key); + + public IReadOnlyCollection Keys => _data.Keys.ToList(); +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs index 49044ad..8cd9c22 100644 --- a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/Pages/FormEngine/RenderFormModelTests.cs @@ -2,6 +2,7 @@ using AutoFixture; using AutoFixture.AutoNSubstitute; using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Validation; using GovUK.Dfe.FlexForms.Domain.Models; @@ -86,6 +87,30 @@ public RenderFormModelTests() infectedFileStore.IsFileNameInfected(Arg.Any(), Arg.Any()).Returns(false); _fixture.Register(() => infectedFileStore); + var sessionStore = Substitute.For(); + sessionStore.GetString(Arg.Any()).Returns(call => + { + var key = call.Arg(); + return _session.TryGetValue(key, out var bytes) && bytes is { Length: > 0 } + ? System.Text.Encoding.UTF8.GetString(bytes) + : null; + }); + _fixture.Register(() => sessionStore); + _fixture.Register(() => new PostedFormDataBinder()); + _fixture.Register(() => new CollectionFlowProgressStore(sessionStore)); + + var infectedFilter = Substitute.For(); + infectedFilter.FilterList(Arg.Any>(), Arg.Any()) + .Returns(call => (call.Arg>() ?? []).ToList()); + infectedFilter.FilterUploadDataJson(Arg.Any(), Arg.Any()) + .Returns(call => call.ArgAt(0) ?? string.Empty); + _fixture.Register(() => infectedFilter); + + var fileFieldService = Substitute.For(); + fileFieldService.GetFiles(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _fixture.Register(() => fileFieldService); + var conditionalLogic = Substitute.For(); conditionalLogic.ApplyConditionalLogicAsync(default!, default!, default) .ReturnsForAnyArgs(new FormConditionalState()); From 9a752684361be3c1ef5e7877eb96b41ba0b816d5 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 17 Aug 2026 16:05:59 +0100 Subject: [PATCH 03/10] Phase 3 : Domain-only: route parsing and step policy. Extract those rules, keep a single visibility engine, and cover them with table-driven Domain tests. --- GovUK.Dfe.FlexForms.Web.sln | 7 + .../FormEngine/FormRouteParser.cs | 73 ++++++++++ .../FormEngine/FormStepPolicy.cs | 87 ++++++++++++ .../Services/FormNavigationService.cs | 52 +------- .../Services/FormStateManager.cs | 90 +++---------- .../Pages/FormEngine/RenderForm.cshtml.cs | 123 +++++------------ .../FormEngine/FormRouteParserTests.cs | 84 ++++++++++++ .../FormEngine/FormStepPolicyTests.cs | 125 ++++++++++++++++++ .../GovUK.Dfe.FlexForms.Domain.Tests.csproj | 28 ++++ 9 files changed, 462 insertions(+), 207 deletions(-) create mode 100644 src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs create mode 100644 src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj diff --git a/GovUK.Dfe.FlexForms.Web.sln b/GovUK.Dfe.FlexForms.Web.sln index e1dbfef..89054c9 100644 --- a/GovUK.Dfe.FlexForms.Web.sln +++ b/GovUK.Dfe.FlexForms.Web.sln @@ -18,6 +18,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Infrast EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Application.Tests", "src\Tests\GovUK.Dfe.FlexForms.Application.Tests\GovUK.Dfe.FlexForms.Application.Tests.csproj", "{B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Domain.Tests", "src\Tests\GovUK.Dfe.FlexForms.Domain.Tests\GovUK.Dfe.FlexForms.Domain.Tests.csproj", "{E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.ActiveCfg = Release|Any CPU {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -60,6 +66,7 @@ Global {A5568E05-5568-49E3-BF8A-08EA7AB74960} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} {FB4D1E39-01AB-47D4-8394-270993A56B0D} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} + {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471} = {F62DE500-90E9-431D-B84C-8DF4CB166F54} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F20FD7F1-3208-45E6-B45D-AF2EBDF28903} diff --git a/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs new file mode 100644 index 0000000..7b17dda --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormRouteParser.cs @@ -0,0 +1,73 @@ +namespace GovUK.Dfe.FlexForms.Domain.FormEngine; + +public readonly record struct CollectionFlowRoute(string FlowId, string InstanceId, string PageId); + +public readonly record struct DerivedFlowRoute(string FlowId, string ItemId, string PageId); + +/// +/// Parses form-engine page-id routes. Keep the path shapes stable; in-flight URLs depend on them. +/// +public static class FormRouteParser +{ + public const string CollectionFlowSegment = "flow"; + public const string DerivedFlowSegment = "derived"; + + /// + /// Collection flow: flow/{flowId}/{instanceId}/{pageId?} + /// + public static bool TryParseCollectionFlow(string? pageId, out CollectionFlowRoute route) + { + route = default; + if (string.IsNullOrEmpty(pageId)) + return false; + + var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[0].Equals(CollectionFlowSegment, StringComparison.OrdinalIgnoreCase)) + return false; + + route = new CollectionFlowRoute( + parts[1], + parts[2], + parts.Length > 3 ? parts[3] : string.Empty); + return true; + } + + /// + /// Derived collection flow: {flowId}/derived/{itemId}/{pageId?} + /// + public static bool TryParseDerivedFlow(string? pageId, out DerivedFlowRoute route) + { + route = default; + if (string.IsNullOrEmpty(pageId)) + return false; + + var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[1].Equals(DerivedFlowSegment, StringComparison.OrdinalIgnoreCase)) + return false; + + route = new DerivedFlowRoute( + parts[0], + parts[2], + parts.Length > 3 ? parts[3] : string.Empty); + return true; + } + + public static bool LooksLikeCollectionFlow(string? pageId) => + !string.IsNullOrEmpty(pageId) + && pageId.StartsWith($"{CollectionFlowSegment}/", StringComparison.OrdinalIgnoreCase); + + public static bool IsCollectionFlow(string? pageId) => TryParseCollectionFlow(pageId, out _); + + public static bool IsDerivedFlow(string? pageId) => TryParseDerivedFlow(pageId, out _); + + /// + /// Navigation-history scope: {reference}:{task} or {reference}:{task}:flow:{flowId}:{instanceId}. + /// + public static string HistoryScope(string referenceNumber, string taskId, string? pageId) + { + if (TryParseCollectionFlow(pageId, out var route)) + return $"{referenceNumber}:{taskId}:flow:{route.FlowId}:{route.InstanceId}"; + + return $"{referenceNumber}:{taskId}"; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs new file mode 100644 index 0000000..6579843 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Domain/FormEngine/FormStepPolicy.cs @@ -0,0 +1,87 @@ +using GovUK.Dfe.FlexForms.Domain.Models; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Domain.FormEngine; + +/// +/// Pure rules for which engine step to show and how to walk page lists. +/// +public static class FormStepPolicy +{ + public const string MultiCollectionFlowMode = "multiCollectionFlow"; + public const string DerivedCollectionFlowMode = "derivedCollectionFlow"; + + public static bool IsCollectionFlowPage(string? pageId) => FormRouteParser.LooksLikeCollectionFlow(pageId); + + public static bool IsFormPage(string? pageId) => + !string.IsNullOrEmpty(pageId) && !IsCollectionFlowPage(pageId); + + public static bool IsTaskSummary(string? taskId, string? pageId) => + !string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); + + public static bool IsTaskList(string? taskId, string? pageId) => + string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); + + public static bool IsApplicationPreview(string? pageId) => false; + + public static bool IsCollectionFlowSummary(TaskModel? task) => + task?.Summary?.Mode?.Equals(MultiCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true; + + public static bool IsDerivedCollectionFlowSummary(TaskModel? task) => + task?.Summary?.Mode?.Equals(DerivedCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true; + + public static bool IsInSubFlow(string flowId, string? pageId) => + !string.IsNullOrEmpty(pageId) + && pageId.StartsWith($"{FormRouteParser.CollectionFlowSegment}/{flowId}", StringComparison.OrdinalIgnoreCase); + + public static MultiCollectionFlowConfiguration? GetCollectionFlow(TaskModel? task, string flowId) => + task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); + + public static DerivedCollectionFlowConfiguration? GetDerivedFlow(TaskModel? task, string derivedFlowId) => + task?.Summary?.DerivedFlows?.FirstOrDefault(f => f.FlowId == derivedFlowId); + + public static IReadOnlyList? GetCollectionFlowPages(TaskModel? task, string flowId) => + GetCollectionFlow(task, flowId)?.Pages; + + public static string? GetCollectionFlowFieldId(TaskModel? task, string flowId) => + GetCollectionFlow(task, flowId)?.FieldId; + + public static Page? ResolvePage(IReadOnlyList? pages, string? pageId) + { + if (pages == null || pages.Count == 0) + return null; + + return string.IsNullOrEmpty(pageId) + ? pages[0] + : pages.FirstOrDefault(p => p.PageId == pageId); + } + + public static Page? GetNextPage(IReadOnlyList? pages, string currentPageId) + { + var index = IndexOfPage(pages, currentPageId); + if (index == -1 || pages is null || index >= pages.Count - 1) + return null; + + return pages[index + 1]; + } + + public static bool IsLastPage(IReadOnlyList? pages, string currentPageId) + { + var index = IndexOfPage(pages, currentPageId); + return pages == null || pages.Count == 0 || index == -1 || index >= pages.Count - 1; + } + + public static int IndexOfPage(IReadOnlyList? pages, string currentPageId) + { + if (pages == null) + return -1; + + for (var i = 0; i < pages.Count; i++) + { + if (pages[i].PageId == currentPageId) + return i; + } + + return -1; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs index d51b653..b349940 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormNavigationService.cs @@ -1,4 +1,5 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; namespace GovUK.Dfe.FlexForms.Infrastructure.Services { @@ -138,7 +139,7 @@ public string GetNextNavigationTargetAfterSave(Domain.Models.Page currentPage, D } // Find the next page in the same task - var nextPage = GetNextPageInTask(currentPage, currentTask); + var nextPage = FormStepPolicy.GetNextPage(currentTask.Pages, currentPage.PageId); if (nextPage != null) { return $"/applications/{referenceNumber}/{currentTask.TaskId}/{nextPage.PageId}"; @@ -148,31 +149,6 @@ public string GetNextNavigationTargetAfterSave(Domain.Models.Page currentPage, D return GetTaskSummaryUrl(currentTask.TaskId, referenceNumber); } - /// - /// Gets the next page in the same task, or null if there is no next page - /// - /// The current page - /// The current task - /// The next page, or null if there is no next page - private Domain.Models.Page? GetNextPageInTask(Domain.Models.Page currentPage, Domain.Models.Task currentTask) - { - if (currentTask.Pages == null || !currentTask.Pages.Any()) - { - return null; - } - - // Find the current page index - var currentPageIndex = currentTask.Pages.FindIndex(p => p.PageId == currentPage.PageId); - if (currentPageIndex == -1 || currentPageIndex >= currentTask.Pages.Count - 1) - { - // Current page not found or it's the last page - return null; - } - - // Return the next page - return currentTask.Pages[currentPageIndex + 1]; - } - // Sub-flow helpers public string GetCollectionFlowSummaryUrl(string taskId, string referenceNumber) { @@ -190,26 +166,10 @@ public string GetSubFlowPageUrl(string taskId, string referenceNumber, string fl return $"/applications/{referenceNumber}/{taskId}/flow/{flowId}/{instanceId}/{pageId}"; } - private static bool IsSubFlowPage(string currentPageId) - { - return !string.IsNullOrEmpty(currentPageId) && currentPageId.StartsWith("flow/", StringComparison.OrdinalIgnoreCase); - } + private static bool IsSubFlowPage(string currentPageId) => + FormStepPolicy.IsCollectionFlowPage(currentPageId); - private static string BuildScope(string referenceNumber, string taskId, string currentPageId) - { - if (string.IsNullOrEmpty(currentPageId)) - { - return $"{referenceNumber}:{taskId}"; - } - // Extract flow/instance if present: flow/{flowId}/{instanceId}/... - var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase)) - { - var flowId = parts[1]; - var instanceId = parts[2]; - return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}"; - } - return $"{referenceNumber}:{taskId}"; - } + private static string BuildScope(string referenceNumber, string taskId, string currentPageId) => + FormRouteParser.HistoryScope(referenceNumber, taskId, currentPageId); } } diff --git a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs index 0576666..5beff3d 100644 --- a/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs +++ b/src/GovUK.Dfe.FlexForms.Infrastructure/Services/FormStateManager.cs @@ -1,88 +1,42 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; namespace GovUK.Dfe.FlexForms.Infrastructure.Services { /// - /// Implementation of the form state manager that determines which view should be rendered + /// Application adapter over . /// public class FormStateManager : IFormStateManager { - /// - /// Gets the current form state based on the provided parameters - /// - /// The application reference number - /// The current task ID (optional) - /// The current page ID (optional) - /// The current form state public FormState GetCurrentState(string referenceNumber, string taskId, string pageId) { - // Sub-flow routing pattern: pageId may include "flow/flowId/..." segment when mapped in the Razor Page - if (!string.IsNullOrEmpty(pageId) && pageId.StartsWith("flow/", StringComparison.OrdinalIgnoreCase)) - { + if (FormStepPolicy.IsCollectionFlowPage(pageId)) return FormState.SubFlowPage; - } - // If we have a pageId, we're showing a specific form page - if (!string.IsNullOrEmpty(pageId)) - { + + if (FormStepPolicy.IsFormPage(pageId)) return FormState.FormPage; - } - - // If we have a taskId but no pageId, we're showing the task summary - if (!string.IsNullOrEmpty(taskId)) - { + + if (FormStepPolicy.IsTaskSummary(taskId, pageId)) return FormState.TaskSummary; - } - - // If we have neither taskId nor pageId, we're showing the task list + return FormState.TaskList; } - - /// - /// Determines if the task list should be shown - /// - /// The current page ID - /// True if task list should be shown - public bool ShouldShowTaskList(string pageId) - { - return string.IsNullOrEmpty(pageId); - } - - /// - /// Determines if the task summary should be shown - /// - /// The current task ID - /// The current page ID - /// True if task summary should be shown - public bool ShouldShowTaskSummary(string taskId, string pageId) - { - return !string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(pageId); - } - - /// - /// Determines if the application preview should be shown - /// - /// The current page ID - /// True if application preview should be shown - public bool ShouldShowApplicationPreview(string pageId) - { - // This would be determined by specific routing logic - // For now, we'll return false as this is handled by separate pages - return false; - } - public bool ShouldShowCollectionFlowSummary(Domain.Models.Task task) - { - return task?.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true; - } + public bool ShouldShowTaskList(string pageId) => string.IsNullOrEmpty(pageId); - public bool ShouldShowDerivedCollectionFlowSummary(Domain.Models.Task task) - { - return task?.Summary?.Mode?.Equals("derivedCollectionFlow", StringComparison.OrdinalIgnoreCase) == true; - } + public bool ShouldShowTaskSummary(string taskId, string pageId) => + FormStepPolicy.IsTaskSummary(taskId, pageId); - public bool IsInSubFlow(string flowId, string pageId) - { - return !string.IsNullOrEmpty(pageId) && pageId.StartsWith($"flow/{flowId}", StringComparison.OrdinalIgnoreCase); - } + public bool ShouldShowApplicationPreview(string pageId) => + FormStepPolicy.IsApplicationPreview(pageId); + + public bool ShouldShowCollectionFlowSummary(Domain.Models.Task task) => + FormStepPolicy.IsCollectionFlowSummary(task); + + public bool ShouldShowDerivedCollectionFlowSummary(Domain.Models.Task task) => + FormStepPolicy.IsDerivedCollectionFlowSummary(task); + + public bool IsInSubFlow(string flowId, string pageId) => + FormStepPolicy.IsInSubFlow(flowId, pageId); } } diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs index 569ec7e..462bfd8 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs @@ -3,6 +3,7 @@ using GovUK.Dfe.FlexForms.Application.Interfaces; using GovUK.Dfe.FlexForms.Application.Notifications; using GovUK.Dfe.FlexForms.Domain.Caching; +using GovUK.Dfe.FlexForms.Domain.FormEngine; using GovUK.Dfe.FlexForms.Domain.Models; using GovUK.Dfe.FlexForms.Web.Extensions; using GovUK.Dfe.FlexForms.Infrastructure.Services; @@ -211,7 +212,7 @@ public async Task OnGetAsync() } if (flowPages != null) { - var page = string.IsNullOrEmpty(flowPageId) ? flowPages.FirstOrDefault() : flowPages.FirstOrDefault(p => p.PageId == flowPageId); + var page = FormStepPolicy.ResolvePage(flowPages, flowPageId); if (page != null) { CurrentPage = page; @@ -253,7 +254,7 @@ public async Task OnGetAsync() if (derivedConfig != null) { // Get the page to render (default to first page if no specific page) - var page = string.IsNullOrEmpty(derivedPageId) ? derivedConfig.Pages.FirstOrDefault() : derivedConfig.Pages.FirstOrDefault(p => p.PageId == derivedPageId); + var page = FormStepPolicy.ResolvePage(derivedConfig.Pages, derivedPageId); if (page != null) { CurrentPage = page; @@ -364,21 +365,8 @@ public async Task OnGetAsync() catch { } } - public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId) - { - if (string.IsNullOrEmpty(currentPageId)) - { - return $"{referenceNumber}:{taskId}"; - } - var parts = currentPageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && string.Equals(parts[0], "flow", StringComparison.OrdinalIgnoreCase)) - { - var flowId = parts[1]; - var instanceId = parts[2]; - return $"{referenceNumber}:{taskId}:flow:{flowId}:{instanceId}"; - } - return $"{referenceNumber}:{taskId}"; - } + public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId) => + FormRouteParser.HistoryScope(referenceNumber, taskId, currentPageId); public async Task OnPostTaskSummaryAsync() { @@ -731,7 +719,7 @@ public async Task OnPostPageAsync() var flowPages = GetFlowPages(task, flowId); if (flowPages != null) { - var page = string.IsNullOrEmpty(flowPageId) ? flowPages.FirstOrDefault() : flowPages.FirstOrDefault(p => p.PageId == flowPageId); + var page = FormStepPolicy.ResolvePage(flowPages, flowPageId); if (page != null) { CurrentPage = page; @@ -747,9 +735,7 @@ public async Task OnPostPageAsync() var derivedConfig = GetDerivedFlowConfiguration(task, dFlowId); if (derivedConfig != null) { - var page = string.IsNullOrEmpty(dPageId) - ? derivedConfig.Pages?.FirstOrDefault() - : derivedConfig.Pages?.FirstOrDefault(p => p.PageId == dPageId); + var page = FormStepPolicy.ResolvePage(derivedConfig.Pages, dPageId); if (page != null) { CurrentPage = page; @@ -1021,8 +1007,8 @@ await _applicationResponseService.SaveApplicationResponseAsync( } } - var index = flowPages.FindIndex(p => p.PageId == CurrentPage.PageId); - var isLast = index == -1 || index >= flowPages.Count - 1; + var index = FormStepPolicy.IndexOfPage(flowPages, CurrentPage.PageId); + var isLast = FormStepPolicy.IsLastPage(flowPages, CurrentPage.PageId); if (!isLast) { // Find the next visible page using conditional logic @@ -1151,9 +1137,7 @@ await _applicationResponseService.SaveApplicationResponseAsync( var derivedConfig = GetDerivedFlowConfiguration(correctTask, derivedFlowId); if (derivedConfig != null) { - var currentDerivedPage = string.IsNullOrEmpty(derivedPageId) - ? derivedConfig.Pages?.FirstOrDefault() - : derivedConfig.Pages?.FirstOrDefault(p => p.PageId == derivedPageId); + var currentDerivedPage = FormStepPolicy.ResolvePage(derivedConfig.Pages, derivedPageId); if (currentDerivedPage != null) { @@ -1358,15 +1342,7 @@ await _applicationStateService.SaveTaskStatusAsync( } // No conditional next page - find the next page in sequence - Domain.Models.Page? sequentialNextPage = null; - if (CurrentTask.Pages != null && CurrentTask.Pages.Any()) - { - var currentPageIndex = CurrentTask.Pages.FindIndex(p => p.PageId == CurrentPage.PageId); - if (currentPageIndex != -1 && currentPageIndex < CurrentTask.Pages.Count - 1) - { - sequentialNextPage = CurrentTask.Pages[currentPageIndex + 1]; - } - } + var sequentialNextPage = FormStepPolicy.GetNextPage(CurrentTask.Pages, CurrentPage.PageId); if (sequentialNextPage != null) { @@ -1664,79 +1640,40 @@ public async Task OnGetComplexFieldAsync(string complexFieldId, s private static bool TryParseFlowRoute(string pageId, out string flowId, out string instanceId, out string flowPageId) { - flowId = instanceId = flowPageId = string.Empty; - if (string.IsNullOrEmpty(pageId)) return false; - // Expected: flow/{flowId}/{instanceId}/{pageId?} - var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[0].Equals("flow", StringComparison.OrdinalIgnoreCase)) - { - flowId = parts[1]; - instanceId = parts[2]; - flowPageId = parts.Length > 3 ? parts[3] : string.Empty; + if (FormRouteParser.TryParseCollectionFlow(pageId, out var route)) + { + flowId = route.FlowId; + instanceId = route.InstanceId; + flowPageId = route.PageId; return true; } + + flowId = instanceId = flowPageId = string.Empty; return false; } - /// - /// Parses derived collection flow routes like: {flowId}/derived/{itemId}/{pageId?} - /// private static bool TryParseDerivedFlowRoute(string pageId, out string derivedFlowId, out string derivedItemId, out string derivedPageId) { - derivedFlowId = derivedItemId = derivedPageId = string.Empty; - if (string.IsNullOrEmpty(pageId)) return false; - - // Expected: {flowId}/derived/{itemId}/{pageId?} - var parts = pageId.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[1].Equals("derived", StringComparison.OrdinalIgnoreCase)) + if (FormRouteParser.TryParseDerivedFlow(pageId, out var route)) { - derivedFlowId = parts[0]; - derivedItemId = parts[2]; - derivedPageId = parts.Length > 3 ? parts[3] : string.Empty; + derivedFlowId = route.FlowId; + derivedItemId = route.ItemId; + derivedPageId = route.PageId; return true; } + + derivedFlowId = derivedItemId = derivedPageId = string.Empty; return false; } - /// - /// Gets the pages for a specific flow in multi-collection flow mode - /// - private List? GetFlowPages(Domain.Models.Task? task, string flowId) - { - var flow = task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - return flow?.Pages; - } + private static List? GetFlowPages(Domain.Models.Task? task, string flowId) => + FormStepPolicy.GetCollectionFlowPages(task, flowId)?.ToList(); - /// - /// Gets the fieldId for a specific flow in multi-collection flow mode - /// - private string? GetFlowFieldId(Domain.Models.Task? task, string flowId) - { - var flow = task?.Summary?.Flows?.FirstOrDefault(f => f.FlowId == flowId); - return flow?.FieldId; - } + private static string? GetFlowFieldId(Domain.Models.Task? task, string flowId) => + FormStepPolicy.GetCollectionFlowFieldId(task, flowId); - /// - /// Gets the configuration for a specific derived flow - /// - private DerivedCollectionFlowConfiguration? GetDerivedFlowConfiguration(Domain.Models.Task? task, string derivedFlowId) - { - _logger.LogInformation("GetDerivedFlowConfiguration: Looking for flowId='{FlowId}' in task '{TaskId}'", derivedFlowId, task?.TaskId); - _logger.LogInformation("GetDerivedFlowConfiguration: Task summary mode: '{Mode}'", task?.Summary?.Mode); - _logger.LogInformation("GetDerivedFlowConfiguration: DerivedFlows count: {Count}", task?.Summary?.DerivedFlows?.Count ?? 0); - - if (task?.Summary?.DerivedFlows != null) - { - foreach (var flow in task.Summary.DerivedFlows) - { - _logger.LogInformation("GetDerivedFlowConfiguration: Available flow - FlowId='{FlowId}', FieldId='{FieldId}'", flow.FlowId, flow.FieldId); - } - } - - var derivedFlow = task?.Summary?.DerivedFlows?.FirstOrDefault(f => f.FlowId == derivedFlowId); - _logger.LogInformation("GetDerivedFlowConfiguration: Found config: {Found}", derivedFlow != null); - return derivedFlow; - } + private static DerivedCollectionFlowConfiguration? GetDerivedFlowConfiguration(Domain.Models.Task? task, string derivedFlowId) => + FormStepPolicy.GetDerivedFlow(task, derivedFlowId); /// /// Loads pre-filled data for a derived collection item diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs new file mode 100644 index 0000000..9cdd25b --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormRouteParserTests.cs @@ -0,0 +1,84 @@ +using GovUK.Dfe.FlexForms.Domain.FormEngine; + +namespace GovUK.Dfe.FlexForms.Domain.Tests.FormEngine; + +public class FormRouteParserTests +{ + [Theory] + [InlineData("flow/f1/i1/p1", "f1", "i1", "p1")] + [InlineData("FLOW/f1/i1", "f1", "i1", "")] + [InlineData("flow/f1/i1/", "f1", "i1", "")] + public void TryParseCollectionFlow_accepts_valid_routes( + string pageId, + string flowId, + string instanceId, + string flowPageId) + { + Assert.True(FormRouteParser.TryParseCollectionFlow(pageId, out var route)); + Assert.Equal(flowId, route.FlowId); + Assert.Equal(instanceId, route.InstanceId); + Assert.Equal(flowPageId, route.PageId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("task-page")] + [InlineData("flow/only-flow-id")] + [InlineData("f1/derived/item1")] + public void TryParseCollectionFlow_rejects_non_collection_routes(string? pageId) + { + Assert.False(FormRouteParser.TryParseCollectionFlow(pageId, out var route)); + Assert.Equal(default, route); + } + + [Theory] + [InlineData("df1/derived/item1/p1", "df1", "item1", "p1")] + [InlineData("df1/DERIVED/item1", "df1", "item1", "")] + public void TryParseDerivedFlow_accepts_valid_routes( + string pageId, + string flowId, + string itemId, + string derivedPageId) + { + Assert.True(FormRouteParser.TryParseDerivedFlow(pageId, out var route)); + Assert.Equal(flowId, route.FlowId); + Assert.Equal(itemId, route.ItemId); + Assert.Equal(derivedPageId, route.PageId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("flow/f1/i1/p1")] + [InlineData("df1/other/item1")] + [InlineData("derived/item1")] + public void TryParseDerivedFlow_rejects_non_derived_routes(string? pageId) + { + Assert.False(FormRouteParser.TryParseDerivedFlow(pageId, out _)); + } + + [Theory] + [InlineData("flow/f1/i1/p1", true)] + [InlineData("flow/f1", true)] + [InlineData("task-page", false)] + [InlineData(null, false)] + public void LooksLikeCollectionFlow_uses_the_flow_prefix(string? pageId, bool expected) + { + Assert.Equal(expected, FormRouteParser.LooksLikeCollectionFlow(pageId)); + } + + [Theory] + [InlineData("APP-1", "task-1", "flow/f1/i1/p1", "APP-1:task-1:flow:f1:i1")] + [InlineData("APP-1", "task-1", "task-page", "APP-1:task-1")] + [InlineData("APP-1", "task-1", "", "APP-1:task-1")] + [InlineData("APP-1", "task-1", "df1/derived/item1", "APP-1:task-1")] + public void HistoryScope_includes_flow_instance_only_for_collection_routes( + string reference, + string taskId, + string pageId, + string expected) + { + Assert.Equal(expected, FormRouteParser.HistoryScope(reference, taskId, pageId)); + } +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs new file mode 100644 index 0000000..71054d7 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/FormEngine/FormStepPolicyTests.cs @@ -0,0 +1,125 @@ +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Domain.Tests.FormEngine; + +public class FormStepPolicyTests +{ + [Theory] + [InlineData(null, null, true, false, false, false)] + [InlineData("", "", true, false, false, false)] + [InlineData("task-1", null, false, true, false, false)] + [InlineData("task-1", "", false, true, false, false)] + [InlineData("task-1", "page-1", false, false, true, false)] + [InlineData("task-1", "flow/f1/i1/p1", false, false, false, true)] + [InlineData("task-1", "flow/f1", false, false, false, true)] + [InlineData("task-1", "df1/derived/item1", false, false, true, false)] + public void Step_flags_match_task_and_page_id( + string? taskId, + string? pageId, + bool taskList, + bool taskSummary, + bool formPage, + bool collectionFlowPage) + { + Assert.Equal(taskList, FormStepPolicy.IsTaskList(taskId, pageId)); + Assert.Equal(taskSummary, FormStepPolicy.IsTaskSummary(taskId, pageId)); + Assert.Equal(formPage, FormStepPolicy.IsFormPage(pageId)); + Assert.Equal(collectionFlowPage, FormStepPolicy.IsCollectionFlowPage(pageId)); + Assert.False(FormStepPolicy.IsApplicationPreview(pageId)); + } + + [Fact] + public void Summary_mode_flags_follow_task_configuration() + { + var collection = TaskWithMode(FormStepPolicy.MultiCollectionFlowMode); + var derived = TaskWithMode(FormStepPolicy.DerivedCollectionFlowMode); + var standard = TaskWithMode("standard"); + + Assert.True(FormStepPolicy.IsCollectionFlowSummary(collection)); + Assert.False(FormStepPolicy.IsDerivedCollectionFlowSummary(collection)); + Assert.True(FormStepPolicy.IsDerivedCollectionFlowSummary(derived)); + Assert.False(FormStepPolicy.IsCollectionFlowSummary(standard)); + Assert.False(FormStepPolicy.IsCollectionFlowSummary(null)); + } + + [Fact] + public void IsInSubFlow_matches_flow_id_prefix() + { + Assert.True(FormStepPolicy.IsInSubFlow("f1", "flow/f1/i1/p1")); + Assert.False(FormStepPolicy.IsInSubFlow("f1", "flow/f2/i1/p1")); + Assert.False(FormStepPolicy.IsInSubFlow("f1", "page-1")); + } + + [Fact] + public void ResolvePage_returns_first_page_when_id_is_missing() + { + var pages = new[] { Page("p1"), Page("p2") }; + + Assert.Equal("p1", FormStepPolicy.ResolvePage(pages, null)?.PageId); + Assert.Equal("p2", FormStepPolicy.ResolvePage(pages, "p2")?.PageId); + Assert.Null(FormStepPolicy.ResolvePage(pages, "missing")); + Assert.Null(FormStepPolicy.ResolvePage(null, "p1")); + } + + [Fact] + public void GetNextPage_and_IsLastPage_walk_the_list() + { + var pages = new[] { Page("p1"), Page("p2"), Page("p3") }; + + Assert.Equal("p2", FormStepPolicy.GetNextPage(pages, "p1")?.PageId); + Assert.Equal("p3", FormStepPolicy.GetNextPage(pages, "p2")?.PageId); + Assert.Null(FormStepPolicy.GetNextPage(pages, "p3")); + Assert.Null(FormStepPolicy.GetNextPage(pages, "missing")); + + Assert.False(FormStepPolicy.IsLastPage(pages, "p1")); + Assert.True(FormStepPolicy.IsLastPage(pages, "p3")); + Assert.True(FormStepPolicy.IsLastPage(pages, "missing")); + Assert.Equal(1, FormStepPolicy.IndexOfPage(pages, "p2")); + } + + [Fact] + public void Collection_flow_lookups_use_flow_id() + { + var flow = new MultiCollectionFlowConfiguration + { + FlowId = "f1", + FieldId = "collection", + Pages = [Page("p1")] + }; + var task = new TaskModel + { + TaskId = "t1", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Flows = [flow] } + }; + + Assert.Equal("collection", FormStepPolicy.GetCollectionFlowFieldId(task, "f1")); + Assert.Equal("p1", FormStepPolicy.GetCollectionFlowPages(task, "f1")?[0].PageId); + Assert.Null(FormStepPolicy.GetCollectionFlow(task, "missing")); + } + + private static TaskModel TaskWithMode(string mode) => + new() + { + TaskId = "t1", + TaskName = "Task", + TaskOrder = 1, + TaskStatusString = "NotStarted", + Summary = new TaskSummaryConfiguration { Mode = mode } + }; + + private static Page Page(string id) => + new() + { + PageId = id, + Slug = id, + Title = id, + Description = id, + PageOrder = 1, + Fields = [] + }; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj new file mode 100644 index 0000000..9713a7d --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Domain.Tests/GovUK.Dfe.FlexForms.Domain.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + From 5358ebe076b9e9c9974a290fcd2f024e54dc7eb5 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Mon, 17 Aug 2026 16:30:11 +0100 Subject: [PATCH 04/10] Phase 4 : preview and collection-flow Razor now iterate GET-built view models instead of doing the formatting work themselves. --- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Pages/FormEngine/RenderForm.cshtml | 8 +- .../Pages/FormEngine/RenderForm.cshtml.cs | 81 ++ .../FormEngine/ApplicationPreviewViewModel.cs | 8 + .../AutocompleteSummaryFormatter.cs | 111 +++ .../FormEngine/CollectionFlowItemViewModel.cs | 10 + .../CollectionFlowSectionViewModel.cs | 17 + .../CollectionItemRemoveViewModel.cs | 15 + .../FormEnginePresentationComposer.cs | 766 ++++++++++++++++++ .../FormEnginePresentationContext.cs | 25 + .../IFormEnginePresentationComposer.cs | 21 + .../FormEngine/PreviewGroupViewModel.cs | 8 + .../FormEngine/PreviewSubmitViewModel.cs | 19 + .../FormEngine/PreviewTaskCardViewModel.cs | 10 + .../FormEngine/SummaryDisplayKind.cs | 14 + .../FormEngine/SummaryFileLinkViewModel.cs | 11 + .../FormEngine/SummaryRowViewModel.cs | 12 + .../FormEngine/SummaryValueViewModel.cs | 51 ++ .../FormEngine/_ApplicationPreview.cshtml | 661 +-------------- .../FormEngine/_CollectionFlowSummary.cshtml | 9 +- .../FormEngine/_CollectionItemRemove.cshtml | 18 + .../FormEngine/_FileDownloadButton.cshtml | 21 + .../FormEngine/_SingleCollectionFlow.cshtml | 560 +------------ .../Shared/FormEngine/_SummaryRow.cshtml | 29 + .../Shared/FormEngine/_SummaryValue.cshtml | 54 ++ .../AutocompleteSummaryFormatterTests.cs | 65 ++ .../FormEnginePresentationComposerTests.cs | 303 +++++++ 27 files changed, 1720 insertions(+), 1189 deletions(-) create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs create mode 100644 src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml create mode 100644 src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml create mode 100644 src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml create mode 100644 src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs create mode 100644 src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/FormEnginePresentationComposerTests.cs diff --git a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs index 24e30b0..0a266c9 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Extensions/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using GovUK.Dfe.FlexForms.Web.Configuration; using GovUK.Dfe.FlexForms.Web.Interfaces; using GovUK.Dfe.FlexForms.Web.Services; +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; using GovUK.Dfe.FlexForms.Api.Client; using GovUK.Dfe.FlexForms.Api.Client.Contracts; using GovUK.Dfe.FlexForms.Api.Client.Extensions; @@ -82,6 +83,7 @@ public static IServiceCollection AddWebLayerServices(this IServiceCollection ser services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml index a9d86ce..0fe399a 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml @@ -1,11 +1,10 @@ @page "/applications/{referenceNumber}/{taskId?}/{*pageId}" -@using GovUK.Dfe.FlexForms.Infrastructure.Services @using GovUK.Dfe.FlexForms.Application.Interfaces +@using GovUK.Dfe.FlexForms.Domain.FormEngine @using GovUk.Frontend.AspNetCore.TagHelpers @model RenderFormModel @{ ViewData["Title"] = "New application"; - var allTasksCompleted = Model.AreAllTasksCompleted(); } @section BeforeContent { @@ -38,8 +37,7 @@ @await Html.PartialAsync("FormEngine/_FormPage", Model) break; case FormState.TaskSummary: - // If this task declares a multiCollectionFlow summary, render the custom summary - if (Model.CurrentTask?.Summary?.Mode?.ToLowerInvariant() == "multicollectionflow") + if (FormStepPolicy.IsCollectionFlowSummary(Model.CurrentTask)) { @await Html.PartialAsync("FormEngine/_CollectionFlowSummary", Model) } @@ -54,4 +52,4 @@ case FormState.ApplicationPreview: @await Html.PartialAsync("FormEngine/_ApplicationPreview", Model) break; -} \ No newline at end of file +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs index 462bfd8..f6524e2 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs +++ b/src/GovUK.Dfe.FlexForms.Web/Pages/FormEngine/RenderForm.cshtml.cs @@ -11,14 +11,17 @@ using GovUK.Dfe.FlexForms.Web.Interfaces; using GovUK.Dfe.FlexForms.Web.Pages.Shared; using GovUK.Dfe.FlexForms.Web.Services; +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; 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.FlexForms.Api.Client.Contracts; using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; using System.Text.Json; using System.Threading; using static GovUK.Dfe.FlexForms.Web.Pages.FormEngine.DisplayHelpers; @@ -51,6 +54,7 @@ public class RenderFormModel( IInfectedUploadFilter infectedUploadFilter, IFormFileFieldService formFileFieldService, IPostedFormDataBinder postedFormDataBinder, + IFormEnginePresentationComposer formEnginePresentationComposer, ILogger logger, INavigationHistoryService navigationHistoryService, IRequestAppConfiguration requestConfiguration) @@ -67,6 +71,7 @@ public class RenderFormModel( private readonly IInfectedUploadFilter _infectedUploadFilter = infectedUploadFilter; private readonly IFormFileFieldService _formFileFieldService = formFileFieldService; private readonly IPostedFormDataBinder _postedFormDataBinder = postedFormDataBinder; + private readonly IFormEnginePresentationComposer _formEnginePresentationComposer = formEnginePresentationComposer; private readonly IFieldRequirementService _fieldRequirementService = fieldRequirementService; private readonly INavigationHistoryService _navigationHistoryService = navigationHistoryService; private readonly IRequestAppConfiguration _requestConfiguration = requestConfiguration; @@ -110,6 +115,10 @@ public class RenderFormModel( public IReadOnlyList FileValidationBlockingFiles { get; set; } = []; + public ApplicationPreviewViewModel? Preview { get; private set; } + + public IReadOnlyList CollectionFlows { get; private set; } = []; + // Conditional logic state for the current form public FormConditionalState? ConditionalState { get; set; } @@ -365,9 +374,81 @@ public async Task OnGetAsync() catch { } } + public override void OnPageHandlerExecuted(PageHandlerExecutedContext context) + { + BuildPresentationViewModels(); + base.OnPageHandlerExecuted(context); + } + public static string BuildHistoryScope(string referenceNumber, string taskId, string currentPageId) => FormRouteParser.HistoryScope(referenceNumber, taskId, currentPageId); + private void BuildPresentationViewModels() + { + if (Template == null) + return; + + var presentationContext = CreatePresentationContext(); + + if (CurrentFormState == FormState.ApplicationPreview) + { + Preview = _formEnginePresentationComposer.BuildPreview(presentationContext); + } + + if (CurrentFormState == FormState.TaskSummary + && CurrentTask != null + && FormStepPolicy.IsCollectionFlowSummary(CurrentTask)) + { + CollectionFlows = _formEnginePresentationComposer.BuildCollectionFlows(presentationContext, CurrentTask); + } + } + + private FormEnginePresentationContext CreatePresentationContext() + { + var submitDisabled = _requestConfiguration.GetSection("Layout:SubmitAppDisabled").Exists(); + return new FormEnginePresentationContext + { + Template = Template, + FormData = FormData, + ReferenceNumber = ReferenceNumber, + TaskId = TaskId, + ApplicationId = ApplicationId, + InfectedFilterApplicationId = ApplicationId?.ToString() + ?? HttpContext.Session.GetString(FormSessionKeys.ApplicationId), + IsEditable = IsApplicationEditable(), + IsLeadApplicant = IsCurrentUserLeadApplicant(), + SubmitDisabledByConfig = submitDisabled, + SubmitDisabledBannerText = submitDisabled + ? _requestConfiguration["Layout:SubmitAppDisabled:BannerText"] + : null, + SubmitDisabledHelpText = submitDisabled + ? _requestConfiguration["Layout:SubmitAppDisabled:HelpText"] + : null, + FileValidationBlocksSubmit = FileValidationBlocksSubmit, + BlockingFiles = FileValidationBlockingFiles, + IncludePreviewQuery = Request.Query.ContainsKey("preview"), + EnsureItemFieldVisibility = EnsureItemFieldVisibility, + IsFieldHiddenForItem = IsFieldHiddenForItem, + IsFieldHidden = IsFieldHidden + }; + } + + private bool IsCurrentUserLeadApplicant() + { + var applicationId = HttpContext.Session.GetString(FormSessionKeys.ApplicationId); + var leadApplicantEmail = HttpContext.Session.GetString($"ApplicationLeadApplicantEmail_{applicationId}"); + var currentUserEmail = User.FindFirst(ClaimTypes.Email)?.Value + ?? User.FindFirst("email")?.Value + ?? User.FindFirst("sub")?.Value + ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.Identity?.Name; + + return string.Equals( + currentUserEmail?.Trim(), + leadApplicantEmail?.Trim(), + StringComparison.InvariantCultureIgnoreCase); + } + public async Task OnPostTaskSummaryAsync() { await CommonFormEngineInitializationAsync(); diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs new file mode 100644 index 0000000..156c423 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/ApplicationPreviewViewModel.cs @@ -0,0 +1,8 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class ApplicationPreviewViewModel +{ + public required string ReferenceNumber { get; init; } + public required IReadOnlyList Groups { get; init; } + public required PreviewSubmitViewModel Submit { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs new file mode 100644 index 0000000..5fc26e9 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/AutocompleteSummaryFormatter.cs @@ -0,0 +1,111 @@ +using System.Text; +using System.Text.Json; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Formats autocomplete JSON objects into the HTML used on confirmation and preview pages. +/// +public static class AutocompleteSummaryFormatter +{ + public static string Render(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + return string.Empty; + + try + { + using var doc = JsonDocument.Parse(rawValue); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return System.Net.WebUtility.HtmlEncode(rawValue); + + var root = doc.RootElement; + var name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? string.Empty + : string.Empty; + var postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String + ? pc.GetString() ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(postcode) + && root.TryGetProperty("postCode", out var pc2) + && pc2.ValueKind == JsonValueKind.String) + { + postcode = pc2.GetString() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(postcode) + && root.TryGetProperty("address", out var addr) + && addr.ValueKind == JsonValueKind.Object) + { + if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) + postcode = apc.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(postcode) + && addr.TryGetProperty("postCode", out var apc2) + && apc2.ValueKind == JsonValueKind.String) + { + postcode = apc2.GetString() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(postcode) + && addr.TryGetProperty("postalCode", out var apc3) + && apc3.ValueKind == JsonValueKind.String) + { + postcode = apc3.GetString() ?? string.Empty; + } + } + + var ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; + var companiesHouse = root.TryGetProperty("companiesHouseNumber", out var c) + && c.ValueKind == JsonValueKind.String + ? c.GetString() ?? string.Empty + : string.Empty; + if (string.IsNullOrWhiteSpace(companiesHouse) && root.TryGetProperty("companiesHousenumber", out var c2)) + companiesHouse = c2.ToString(); + + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(name)) + sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); + if (!string.IsNullOrWhiteSpace(postcode)) + sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); + if (!string.IsNullOrWhiteSpace(ukprn)) + sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); + if (!string.IsNullOrWhiteSpace(companiesHouse)) + sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(companiesHouse)}"); + return sb.ToString(); + } + catch (JsonException) + { + return System.Net.WebUtility.HtmlEncode(rawValue); + } + } + + public static string TryFindJsonInItem(Dictionary item) + { + foreach (var kv in item) + { + var s = kv.Value?.ToString(); + if (string.IsNullOrWhiteSpace(s)) + continue; + + try + { + using var doc = JsonDocument.Parse(s); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + continue; + + if (doc.RootElement.TryGetProperty("name", out _) + || doc.RootElement.TryGetProperty("ukprn", out _) + || doc.RootElement.TryGetProperty("companiesHouseNumber", out _)) + { + return s; + } + } + catch (JsonException) + { + // Value is not autocomplete JSON; keep scanning other fields. + } + } + + return string.Empty; + } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs new file mode 100644 index 0000000..377b2f3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowItemViewModel.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionFlowItemViewModel +{ + public required string ItemId { get; init; } + public required string Title { get; init; } + public required CollectionItemRemoveViewModel Remove { get; init; } + public required SummaryRowViewModel HeaderRow { get; init; } + public required IReadOnlyList Rows { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs new file mode 100644 index 0000000..cf2c48f --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionFlowSectionViewModel.cs @@ -0,0 +1,17 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionFlowSectionViewModel +{ + public required string FlowId { get; init; } + public required string Title { get; init; } + public string? DescriptionHtml { get; init; } + public required string ItemKind { get; init; } + public required string ItemKindPlural { get; init; } + public required string AddButtonLabel { get; init; } + public required string AddButtonId { get; init; } + public required string AddUrl { get; init; } + public required string NoItemsHintId { get; init; } + public bool CanAddMore { get; init; } + public bool IsListStyle { get; init; } + public required IReadOnlyList Items { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs new file mode 100644 index 0000000..a655436 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/CollectionItemRemoveViewModel.cs @@ -0,0 +1,15 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class CollectionItemRemoveViewModel +{ + public required string ReferenceNumber { get; init; } + public required string TaskId { get; init; } + public required string FlowId { get; init; } + public required string FieldId { get; init; } + public required string ItemId { get; init; } + public required string ItemTitle { get; init; } + public required string TaskName { get; init; } + public required string ConfirmationTitle { get; init; } + public required string RequiredMessage { get; init; } + public required string ButtonId { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs new file mode 100644 index 0000000..0d81da3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationComposer.cs @@ -0,0 +1,766 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Application.FormEngine; +using GovUK.Dfe.FlexForms.Application.Interfaces; +using GovUK.Dfe.FlexForms.Domain.FormEngine; +using GovUK.Dfe.FlexForms.Domain.Models; +using GovUK.Dfe.FlexForms.Infrastructure.Services; +using GovUK.Dfe.FlexForms.Web.Pages.FormEngine; +using GovUK.Dfe.FlexForms.Web.Utilities; +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Composes preview and collection-flow view models from template data and formatting services. +/// +public sealed class FormEnginePresentationComposer( + IFieldFormattingService fieldFormattingService, + IComplexFieldConfigurationService complexFieldConfigurationService, + IInfectedUploadFilter infectedUploadFilter, + IDerivedCollectionFlowService derivedCollectionFlowService) : IFormEnginePresentationComposer +{ + public ApplicationPreviewViewModel BuildPreview(FormEnginePresentationContext context) + { + var groups = context.Template.TaskGroups + .OrderBy(g => g.GroupOrder) + .Select(group => new PreviewGroupViewModel + { + GroupName = group.GroupName, + TestId = ToTestId(group.GroupName), + Tasks = group.Tasks + .OrderBy(t => t.TaskOrder) + .Select(task => BuildPreviewTask(context, task)) + .ToList() + }) + .ToList(); + + return new ApplicationPreviewViewModel + { + ReferenceNumber = context.ReferenceNumber, + Groups = groups, + Submit = new PreviewSubmitViewModel + { + IsEditable = context.IsEditable, + IsLeadApplicant = context.IsLeadApplicant, + SubmitDisabledByConfig = context.SubmitDisabledByConfig, + DisabledBannerText = context.SubmitDisabledBannerText, + DisabledHelpText = context.SubmitDisabledHelpText, + FileValidationBlocksSubmit = context.FileValidationBlocksSubmit, + BlockingFiles = context.BlockingFiles, + IncludePreviewQuery = context.IncludePreviewQuery + } + }; + } + + public IReadOnlyList BuildCollectionFlows( + FormEnginePresentationContext context, + TaskModel task) + { + var flows = task.Summary?.Flows; + if (flows == null || flows.Count == 0) + return []; + + return flows.Select(flow => BuildCollectionSection(context, task, flow)).ToList(); + } + + private PreviewTaskCardViewModel BuildPreviewTask(FormEnginePresentationContext context, TaskModel task) + { + var testId = ToTestId(task.TaskName); + var changeUrl = $"/applications/{context.ReferenceNumber}/{task.TaskId}"; + IReadOnlyList rows; + + if (FormStepPolicy.IsDerivedCollectionFlowSummary(task) + || (task.Summary?.DerivedFlows != null && task.Summary.DerivedFlows.Count > 0)) + { + rows = BuildDerivedPreviewRows(context, task); + } + else if (FormStepPolicy.IsCollectionFlowSummary(task)) + { + rows = BuildCollectionPreviewRows(context, task); + } + else + { + rows = BuildRegularPreviewRows(context, task); + } + + return new PreviewTaskCardViewModel + { + TaskId = task.TaskId, + TaskName = task.TaskName, + TestId = testId, + ChangeUrl = changeUrl, + Rows = rows + }; + } + + private List BuildDerivedPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var derivedFlow in (task.Summary?.DerivedFlows ?? []).OrderBy(f => f.SectionOrder)) + { + rows.Add(HeaderRow(derivedFlow.Title)); + + var derivedItems = derivedCollectionFlowService.GenerateItemsFromSourceField( + derivedFlow.SourceFieldId, context.FormData, derivedFlow); + + if (derivedItems.Count == 0) + { + rows.Add(new SummaryRowViewModel + { + Key = "No items", + Value = SummaryValueViewModel.FromHtml( + $"{System.Net.WebUtility.HtmlEncode(derivedFlow.EmptyStateMessage ?? "No items to display")}") + }); + continue; + } + + var statuses = derivedCollectionFlowService.GetItemStatuses(derivedFlow.FieldId, context.FormData); + foreach (var item in derivedItems) + { + var declarationData = derivedCollectionFlowService.GetItemDeclarationData( + derivedFlow.FieldId, item.Id, context.FormData); + var status = statuses.TryGetValue(item.Id, out var s) ? s : "Not signed yet"; + + rows.Add(new SummaryRowViewModel + { + Key = item.DisplayName, + KeyIsBold = true, + Value = SummaryValueViewModel.FromStatusTag(status) + }); + + foreach (var page in (derivedFlow.Pages ?? []).OrderBy(p => p.PageOrder)) + { + foreach (var field in page.Fields.OrderBy(f => f.Order)) + { + var fieldValue = declarationData.TryGetValue(field.FieldId, out var v) + ? v?.ToString() ?? string.Empty + : string.Empty; + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = BuildDerivedFieldValue(context, task, field, fieldValue) + }); + } + } + } + } + + return rows; + } + + private SummaryValueViewModel BuildDerivedFieldValue( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue) + { + if (string.IsNullOrEmpty(fieldValue)) + return SummaryValueViewModel.NotAnswered; + + if (LooksLikeUploadJson(fieldValue)) + { + return TryBuildUploadValue( + fieldValue, + [ToHtmlBreaks(fieldValue)], + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue))); + } + + if (field.Type == "radios" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromHtml(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "select" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromHtml(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "checkboxes" && field.Options != null) + { + var selectedValues = fieldFormattingService.GetFormattedFieldValues(field.FieldId, context.FormData); + var selectedLabels = field.Options + .Where(o => selectedValues.Contains(o.Value)) + .Select(o => o.Label ?? o.Value) + .ToList(); + + return selectedLabels.Count > 0 + ? SummaryValueViewModel.FromCheckboxes(selectedLabels) + : SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + return SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + private List BuildCollectionPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var flow in task.Summary?.Flows ?? []) + { + rows.Add(HeaderRow(flow.Title)); + + var items = DeserializeItems(context.FormData, flow.FieldId); + if (items.Count == 0) + { + rows.Add(new SummaryRowViewModel + { + Key = "No items added", + Value = SummaryValueViewModel.NotAnswered + }); + continue; + } + + var itemLabel = flow.ItemKind ?? "Item"; + var summaryColumns = flow.SummaryColumns ?? []; + var itemIndex = 0; + foreach (var item in items) + { + itemIndex++; + var expandedItem = DisplayHelpers.ExpandEncodedJson(item) ?? item; + context.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); + var displayTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) + ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) + : $"{itemLabel} {itemIndex}"; + + rows.Add(HeaderRow(displayTitle)); + + foreach (var col in summaryColumns.Where(c => !context.IsFieldHiddenForItem(c.Field, item))) + { + var rawValue = DisplayHelpers.InterpolateMessage($"{{{col.Field}}}", expandedItem); + var value = rawValue == $"{{{col.Field}}}" ? string.Empty : rawValue; + rows.Add(new SummaryRowViewModel + { + Key = col.Label, + Value = string.IsNullOrEmpty(value) + ? SummaryValueViewModel.NotAnswered + : BuildPreviewCollectionFieldValue(context, task, col.Field, value) + }); + } + } + } + + return rows; + } + + private SummaryValueViewModel BuildPreviewCollectionFieldValue( + FormEnginePresentationContext context, + TaskModel task, + string fieldId, + string value) + { + var formattedValues = FormatWithItemValue(context.FormData, fieldId, value); + var isUploadField = LooksLikeUploadJson(value); + + if (formattedValues.Count == 0) + return SummaryValueViewModel.NotAnswered; + + if (formattedValues.Count == 1) + { + if (!isUploadField) + return SummaryValueViewModel.FromHtml(formattedValues[0]); + + return TryBuildUploadValue( + value, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues[0])); + } + + if (!isUploadField) + return SummaryValueViewModel.FromHtmlList(formattedValues); + + return TryBuildUploadValue( + value, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtmlList(formattedValues)); + } + + private List BuildRegularPreviewRows( + FormEnginePresentationContext context, + TaskModel task) + { + var rows = new List(); + foreach (var page in (task.Pages ?? []).OrderBy(p => p.PageOrder)) + { + foreach (var field in page.Fields.OrderBy(f => f.Order).Where(f => !context.IsFieldHidden(f.FieldId))) + { + var fieldValue = fieldFormattingService.GetFieldValue(field.FieldId, context.FormData); + var hasValue = fieldFormattingService.HasFieldValue(field.FieldId, context.FormData); + + if ((field.Type == "autocomplete" || field.Type == "complexField" || field.Type == "upload") && hasValue) + { + rows.AddRange(BuildRegularComplexRows(context, task, field, fieldValue)); + } + else + { + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = BuildRegularSimpleValue(context, task, field, fieldValue, hasValue) + }); + } + } + } + + return rows; + } + + private List BuildRegularComplexRows( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue) + { + var formattedValues = fieldFormattingService.GetFormattedFieldValues(field.FieldId, context.FormData); + var itemLabel = fieldFormattingService.GetFieldItemLabel(field.FieldId, context.Template); + var allowMultiple = fieldFormattingService.IsFieldAllowMultiple(field.FieldId, context.Template); + var isUploadField = LooksLikeUploadJson(fieldValue); + var rows = new List(); + + SummaryValueViewModel headerValue; + if (formattedValues.Count == 0) + { + headerValue = SummaryValueViewModel.NotAnswered; + } + else if (!allowMultiple) + { + if (isUploadField) + { + headerValue = TryBuildUploadValue( + fieldValue, + formattedValues, + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues.FirstOrDefault() ?? string.Empty)); + } + else + { + var html = AutocompleteSummaryFormatter.Render(DisplayHelpers.UnsanitiseHtmlInput(fieldValue)); + headerValue = SummaryValueViewModel.FromAutocompleteHtml(html); + } + } + else + { + headerValue = SummaryValueViewModel.Empty; + } + + rows.Add(new SummaryRowViewModel + { + Key = field.Label.Value, + Value = headerValue + }); + + if (!allowMultiple || formattedValues.Count == 0) + return rows; + + if (isUploadField && TryParseUploads(fieldValue, out var uploadFiles) && uploadFiles.Count > 0) + { + for (var i = 0; i < uploadFiles.Count; i++) + { + var file = uploadFiles[i]; + rows.Add(new SummaryRowViewModel + { + Key = $"{itemLabel} {i + 1}", + Value = SummaryValueViewModel.FromFiles( + [ToFileLink(file, context, task.TaskId, pageId: null)], + wrapFilesInDivs: false) + }); + } + + return rows; + } + + for (var i = 0; i < formattedValues.Count; i++) + { + rows.Add(new SummaryRowViewModel + { + Key = $"{itemLabel} {i + 1}", + Value = SummaryValueViewModel.FromHtml(formattedValues[i]) + }); + } + + return rows; + } + + private SummaryValueViewModel BuildRegularSimpleValue( + FormEnginePresentationContext context, + TaskModel task, + Field field, + string fieldValue, + bool hasValue) + { + if (!hasValue) + return SummaryValueViewModel.NotAnswered; + + if (LooksLikeUploadJson(fieldValue)) + { + return TryBuildUploadValue( + fieldValue, + [ToHtmlBreaks(fieldValue)], + context, + task.TaskId, + pageId: null, + filterInfected: false, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue))); + } + + if (field.Type == "radios" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromText(selectedOption?.Label ?? fieldValue); + } + + if (field.Type == "select" && field.Options != null) + { + var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); + return SummaryValueViewModel.FromText(selectedOption?.Label ?? fieldValue); + } + + return SummaryValueViewModel.FromHtml(ToHtmlBreaks(fieldValue)); + } + + private CollectionFlowSectionViewModel BuildCollectionSection( + FormEnginePresentationContext context, + TaskModel task, + MultiCollectionFlowConfiguration flow) + { + var items = DeserializeItems(context.FormData, flow.FieldId); + var itemLabel = flow.ItemKind ?? "Item"; + var itemLabelPlural = flow.ItemKindPlural ?? $"{itemLabel}s"; + var isListStyle = flow.TableType?.Equals("list", StringComparison.OrdinalIgnoreCase) == true; + var descriptionHtml = string.IsNullOrEmpty(flow.Description) + ? null + : MarkdownSafe.RenderHintWithClass(flow.Description).html; + + var itemVms = new List(); + var index = 0; + foreach (var item in items) + { + index++; + var expandedItem = DisplayHelpers.ExpandEncodedJson(item) ?? item; + var summaryColumns = flow.SummaryColumns ?? []; + context.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); + var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) + ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) + : $"{itemLabel} {index}"; + var itemId = item.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? string.Empty : string.Empty; + + var remove = new CollectionItemRemoveViewModel + { + ReferenceNumber = context.ReferenceNumber, + TaskId = context.TaskId, + FlowId = flow.FlowId, + FieldId = flow.FieldId, + ItemId = itemId, + ItemTitle = memberTitle, + TaskName = task.TaskName, + ConfirmationTitle = $"Are you sure you want to remove this {itemLabel.ToLower()}?", + RequiredMessage = $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", + ButtonId = "remove-flow-item-@memberNumber" + }; + + itemVms.Add(new CollectionFlowItemViewModel + { + ItemId = itemId, + Title = memberTitle, + Remove = remove, + HeaderRow = new SummaryRowViewModel + { + Key = memberTitle, + KeyIsBold = true, + ShowSeparator = index > 1, + Value = SummaryValueViewModel.Empty, + Remove = remove + }, + Rows = BuildCollectionItemRows(context, flow, item, isListStyle, memberTitle) + }); + } + + return new CollectionFlowSectionViewModel + { + FlowId = flow.FlowId, + Title = flow.Title, + DescriptionHtml = descriptionHtml, + ItemKind = itemLabel, + ItemKindPlural = itemLabelPlural, + AddButtonLabel = flow.AddButtonLabel, + AddButtonId = flow.FlowId + "-add-item", + AddUrl = $"/applications/{context.ReferenceNumber}/{context.TaskId}/flow/{flow.FlowId}/{Guid.NewGuid()}", + NoItemsHintId = flow.FlowId + "-no-items-added-hint", + CanAddMore = !flow.MaxItems.HasValue || items.Count < flow.MaxItems.Value, + IsListStyle = isListStyle, + Items = itemVms + }; + } + + private List BuildCollectionItemRows( + FormEnginePresentationContext context, + MultiCollectionFlowConfiguration flow, + Dictionary item, + bool isListStyle, + string memberTitle) + { + var rows = new List(); + var summaryColumns = flow.SummaryColumns ?? []; + foreach (var col in summaryColumns.Where(c => !context.IsFieldHiddenForItem(c.Field, item))) + { + var value = CoerceItemValue(item.TryGetValue(col.Field, out var v) ? v : null); + var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); + var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? string.Empty; + var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); + var (isAutocompleteField, isUploadFieldByConfig) = DetectComplexFieldTypes(fieldConfig); + + if (!isListStyle && isAutocompleteField && string.IsNullOrEmpty(value)) + { + var inferred = AutocompleteSummaryFormatter.TryFindJsonInItem(item); + if (!string.IsNullOrEmpty(inferred)) + value = inferred; + } + + var changeUrl = $"/applications/{context.ReferenceNumber}/{context.TaskId}/flow/{flow.FlowId}/{(item.TryGetValue("id", out var changeItemId) ? changeItemId?.ToString() : string.Empty)}/{pageId}"; + var changeHiddenText = $"{col.Label} for {memberTitle}"; + + SummaryValueViewModel valueVm; + if (string.IsNullOrEmpty(value)) + { + valueVm = SummaryValueViewModel.NotAnswered; + } + else if (isListStyle && fieldConfig?.Type == "checkboxes") + { + var checkboxValues = CheckboxValueNormalizer.Normalize( + (item.TryGetValue(col.Field, out var valueObj) ? valueObj : null) ?? value); + valueVm = checkboxValues.Count > 0 + ? SummaryValueViewModel.FromCheckboxes(checkboxValues.ToList()) + : BuildCollectionFormattedValue( + context, col.Field, value, item, isAutocompleteField, isUploadFieldByConfig, pageId, unsanitiseAutocomplete: false); + } + else + { + valueVm = BuildCollectionFormattedValue( + context, + col.Field, + value, + item, + isAutocompleteField, + isUploadFieldByConfig, + pageId, + unsanitiseAutocomplete: !isListStyle); + } + + rows.Add(new SummaryRowViewModel + { + Key = col.Label, + Value = valueVm, + ChangeUrl = changeUrl, + ChangeHiddenText = changeHiddenText + }); + } + + return rows; + } + + private SummaryValueViewModel BuildCollectionFormattedValue( + FormEnginePresentationContext context, + string fieldId, + string value, + Dictionary item, + bool isAutocompleteField, + bool isUploadFieldByConfig, + string pageId, + bool unsanitiseAutocomplete) + { + var formattedValues = FormatWithItemValue(context.FormData, fieldId, value); + + if (isAutocompleteField && string.IsNullOrEmpty(value)) + { + var inferred = AutocompleteSummaryFormatter.TryFindJsonInItem(item); + if (!string.IsNullOrEmpty(inferred)) + value = inferred; + } + + var isUploadField = isUploadFieldByConfig || LooksLikeUploadJson(value); + + if (formattedValues.Count == 0) + return SummaryValueViewModel.NotAnswered; + + if (formattedValues.Count == 1) + { + if (isUploadField) + { + return TryBuildUploadValue( + value, + formattedValues, + context, + context.TaskId, + pageId, + filterInfected: true, + showAllFiles: false, + fallbackWhenEmpty: SummaryValueViewModel.FromHtml(formattedValues[0])); + } + + if (isAutocompleteField) + { + var raw = unsanitiseAutocomplete ? DisplayHelpers.UnsanitiseHtmlInput(value) : value; + return SummaryValueViewModel.FromAutocompleteHtml(AutocompleteSummaryFormatter.Render(raw)); + } + + return SummaryValueViewModel.FromHtml(formattedValues[0]); + } + + if (isUploadField) + { + return TryBuildUploadValue( + value, + formattedValues, + context, + context.TaskId, + pageId, + filterInfected: true, + showAllFiles: true, + fallbackWhenEmpty: SummaryValueViewModel.FromHtmlList(formattedValues)); + } + + return SummaryValueViewModel.FromHtmlList(formattedValues); + } + + private (bool IsAutocomplete, bool IsUpload) DetectComplexFieldTypes(Field? fieldConfig) + { + if (fieldConfig is not { Type: "complexField", ComplexField: not null }) + return (false, false); + + var cfg = complexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); + return ( + string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase), + string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase)); + } + + private List FormatWithItemValue(Dictionary formData, string fieldId, string value) + { + var snapshot = new Dictionary(formData) { [fieldId] = value }; + return fieldFormattingService.GetFormattedFieldValues(fieldId, snapshot); + } + + private SummaryValueViewModel TryBuildUploadValue( + string rawValue, + IReadOnlyList formattedValues, + FormEnginePresentationContext context, + string taskId, + string? pageId, + bool filterInfected, + bool showAllFiles, + SummaryValueViewModel fallbackWhenEmpty) + { + if (!TryParseUploads(rawValue, out var uploadFiles)) + { + return showAllFiles && formattedValues.Count > 1 + ? SummaryValueViewModel.FromHtmlList(formattedValues) + : SummaryValueViewModel.FromHtml(formattedValues.Count > 0 ? formattedValues[0] : string.Empty); + } + + if (filterInfected) + uploadFiles = infectedUploadFilter.FilterList(uploadFiles, context.InfectedFilterApplicationId); + + if (uploadFiles.Count == 0) + return fallbackWhenEmpty; + + var files = showAllFiles + ? uploadFiles.Select(f => ToFileLink(f, context, taskId, pageId)).ToList() + : [ToFileLink(uploadFiles[0], context, taskId, pageId)]; + + return SummaryValueViewModel.FromFiles(files, wrapFilesInDivs: showAllFiles); + } + + private static SummaryFileLinkViewModel ToFileLink( + UploadDto file, + FormEnginePresentationContext context, + string taskId, + string? pageId) => + new() + { + FileId = file.Id, + FileName = file.OriginalFileName, + ReferenceNumber = context.ReferenceNumber, + TaskId = taskId, + ApplicationId = context.ApplicationId, + PageId = pageId + }; + + private static SummaryRowViewModel HeaderRow(string title) => + new() + { + Key = title, + KeyIsBold = true, + Value = SummaryValueViewModel.Empty + }; + + private static List> DeserializeItems( + Dictionary formData, + string fieldId) + { + formData.TryGetValue(fieldId, out var raw); + var json = raw?.ToString() ?? "[]"; + try + { + return JsonSerializer.Deserialize>>(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string CoerceItemValue(object? valueObj) => + valueObj switch + { + string sv => sv, + JsonElement je => je.ToString(), + not null => JsonSerializer.Serialize(valueObj), + _ => string.Empty + }; + + private static bool LooksLikeUploadJson(string? value) => + !string.IsNullOrEmpty(value) && value.StartsWith('[') && value.Contains("\"id\""); + + private static bool TryParseUploads(string value, out List files) + { + try + { + files = JsonSerializer.Deserialize>(value) ?? []; + return true; + } + catch (JsonException) + { + files = []; + return false; + } + } + + private static string ToHtmlBreaks(string value) => + value.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
"); + + private static string ToTestId(string name) => name.Replace(" ", "-").ToLower(); +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs new file mode 100644 index 0000000..2cad545 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/FormEnginePresentationContext.cs @@ -0,0 +1,25 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; +using GovUK.Dfe.FlexForms.Domain.Models; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class FormEnginePresentationContext +{ + public required FormTemplate Template { get; init; } + public required Dictionary FormData { get; init; } + public required string ReferenceNumber { get; init; } + public string TaskId { get; init; } = string.Empty; + public Guid? ApplicationId { get; init; } + public string? InfectedFilterApplicationId { get; init; } + public bool IsEditable { get; init; } + public bool IsLeadApplicant { get; init; } + public bool SubmitDisabledByConfig { get; init; } + public string? SubmitDisabledBannerText { get; init; } + public string? SubmitDisabledHelpText { get; init; } + public bool FileValidationBlocksSubmit { get; init; } + public IReadOnlyList BlockingFiles { get; init; } = []; + public bool IncludePreviewQuery { get; init; } + public required Action, IEnumerable> EnsureItemFieldVisibility { get; init; } + public required Func, bool> IsFieldHiddenForItem { get; init; } + public required Func IsFieldHidden { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs new file mode 100644 index 0000000..cf5b05e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/IFormEnginePresentationComposer.cs @@ -0,0 +1,21 @@ +using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +/// +/// Builds Razor-ready view models for application preview and collection-flow summaries. +/// +public interface IFormEnginePresentationComposer +{ + /// + /// Builds the check-your-answers preview from the template and current form data. + /// + ApplicationPreviewViewModel BuildPreview(FormEnginePresentationContext context); + + /// + /// Builds collection-flow sections for a multi-collection task summary. + /// + IReadOnlyList BuildCollectionFlows( + FormEnginePresentationContext context, + TaskModel task); +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs new file mode 100644 index 0000000..e175314 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewGroupViewModel.cs @@ -0,0 +1,8 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewGroupViewModel +{ + public required string GroupName { get; init; } + public required string TestId { get; init; } + public required IReadOnlyList Tasks { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs new file mode 100644 index 0000000..16e9e4e --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewSubmitViewModel.cs @@ -0,0 +1,19 @@ +using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response; + +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewSubmitViewModel +{ + public bool IsEditable { get; init; } + public bool IsLeadApplicant { get; init; } + public bool SubmitDisabledByConfig { get; init; } + public string? DisabledBannerText { get; init; } + public string? DisabledHelpText { get; init; } + public bool FileValidationBlocksSubmit { get; init; } + public IReadOnlyList BlockingFiles { get; init; } = []; + public bool IncludePreviewQuery { get; init; } + + public bool ShowSubmitSection => IsEditable && IsLeadApplicant; + + public bool ShowLeadApplicantInset => IsEditable && !IsLeadApplicant; +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs new file mode 100644 index 0000000..0ad6b85 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/PreviewTaskCardViewModel.cs @@ -0,0 +1,10 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class PreviewTaskCardViewModel +{ + public required string TaskId { get; init; } + public required string TaskName { get; init; } + public required string TestId { get; init; } + public required string ChangeUrl { get; init; } + public required IReadOnlyList Rows { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs new file mode 100644 index 0000000..94e8640 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryDisplayKind.cs @@ -0,0 +1,14 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public enum SummaryDisplayKind +{ + NotAnswered, + Empty, + Html, + Text, + HtmlList, + UploadFiles, + Checkboxes, + AutocompleteHtml, + StatusTag +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs new file mode 100644 index 0000000..91e08a3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryFileLinkViewModel.cs @@ -0,0 +1,11 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryFileLinkViewModel +{ + public required Guid FileId { get; init; } + public required string FileName { get; init; } + public required string ReferenceNumber { get; init; } + public required string TaskId { get; init; } + public Guid? ApplicationId { get; init; } + public string? PageId { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs new file mode 100644 index 0000000..f6a6d7a --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryRowViewModel.cs @@ -0,0 +1,12 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryRowViewModel +{ + public required string Key { get; init; } + public bool KeyIsBold { get; init; } + public bool ShowSeparator { get; init; } + public required SummaryValueViewModel Value { get; init; } + public string? ChangeUrl { get; init; } + public string? ChangeHiddenText { get; init; } + public CollectionItemRemoveViewModel? Remove { get; init; } +} diff --git a/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs new file mode 100644 index 0000000..6f8d354 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/ViewModels/FormEngine/SummaryValueViewModel.cs @@ -0,0 +1,51 @@ +namespace GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +public sealed class SummaryValueViewModel +{ + public SummaryDisplayKind Kind { get; init; } + public string? Text { get; init; } + public string? Html { get; init; } + public IReadOnlyList HtmlItems { get; init; } = []; + public IReadOnlyList Files { get; init; } = []; + public bool WrapFilesInDivs { get; init; } + public IReadOnlyList Checkboxes { get; init; } = []; + public string? StatusText { get; init; } + public bool StatusIsSigned { get; init; } + + public static SummaryValueViewModel NotAnswered { get; } = new() { Kind = SummaryDisplayKind.NotAnswered }; + + public static SummaryValueViewModel Empty { get; } = new() { Kind = SummaryDisplayKind.Empty }; + + public static SummaryValueViewModel FromHtml(string html) => + new() { Kind = SummaryDisplayKind.Html, Html = html }; + + public static SummaryValueViewModel FromText(string text) => + new() { Kind = SummaryDisplayKind.Text, Text = text }; + + public static SummaryValueViewModel FromHtmlList(IReadOnlyList items) => + new() { Kind = SummaryDisplayKind.HtmlList, HtmlItems = items }; + + public static SummaryValueViewModel FromCheckboxes(IReadOnlyList items) => + new() { Kind = SummaryDisplayKind.Checkboxes, Checkboxes = items }; + + public static SummaryValueViewModel FromAutocompleteHtml(string html) => + new() { Kind = SummaryDisplayKind.AutocompleteHtml, Html = html }; + + public static SummaryValueViewModel FromStatusTag(string status) => + new() + { + Kind = SummaryDisplayKind.StatusTag, + StatusText = status, + StatusIsSigned = string.Equals(status, "Signed", StringComparison.Ordinal) + }; + + public static SummaryValueViewModel FromFiles( + IReadOnlyList files, + bool wrapFilesInDivs) => + new() + { + Kind = SummaryDisplayKind.UploadFiles, + Files = files, + WrapFilesInDivs = wrapFilesInDivs + }; +} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml index 85149c5..96c3b8c 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_ApplicationPreview.cshtml @@ -1,34 +1,18 @@ -@using System.Security.Claims -@using System.Text.Json @model GovUK.Dfe.FlexForms.Web.Pages.FormEngine.RenderFormModel -@inject GovUK.Dfe.FlexForms.Application.Interfaces.IDerivedCollectionFlowService DerivedFlowService -@using GovUK.Dfe.FlexForms.Web.Tenancy -@inject ITenantAppConfiguration AppConfig - +@using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine @{ - var applicationId = Context.Session.GetString("ApplicationId"); - var leadApplicantEmail = Context.Session.GetString($"ApplicationLeadApplicantEmail_{applicationId}"); - - // Use robust claim checking like authentication strategies do - var currentUserEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value - ?? Context.User?.FindFirst("email")?.Value - ?? Context.User?.FindFirst("sub")?.Value - ?? Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? Context.User?.Identity?.Name; - - var isLeadApplicant = string.Equals(currentUserEmail?.Trim(), leadApplicantEmail?.Trim(), StringComparison.InvariantCultureIgnoreCase); - - bool submitIsDisabled = AppConfig.GetSection("Layout:SubmitAppDisabled").Exists(); + var preview = Model.Preview; + var submit = preview?.Submit; } -@if (submitIsDisabled) +@if (submit?.SubmitDisabledByConfig == true) {

Important

- @AppConfig["Layout:SubmitAppDisabled:BannerText"] + @submit.DisabledBannerText You can still work on your @AppTerminology.Singular and save it.
@@ -49,392 +33,24 @@ } - @foreach (var group in Model.Template.TaskGroups.OrderBy(g => g.GroupOrder)) + @if (preview != null) { - - var groupNameHyphenated = group.GroupName.Replace(" ", "-").ToLower(); - -

- @group.GroupName -

- - @foreach (var task in group.Tasks.OrderBy(t => t.TaskOrder)) + @foreach (var group in preview.Groups) { - @if (task.Summary?.Mode?.Equals("derivedCollectionFlow", StringComparison.OrdinalIgnoreCase) == true || (task.Summary?.DerivedFlows != null && task.Summary.DerivedFlows.Any())) - { - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
-
-

@task.TaskName

- @if (Model.IsApplicationEditable()) - { - - } -
-
-
- @foreach (var derivedFlow in (task.Summary.DerivedFlows ?? new List()).OrderBy(f => f.SectionOrder)) - { - var derivedItems = DerivedFlowService.GenerateItemsFromSourceField( - derivedFlow.SourceFieldId, Model.FormData, derivedFlow); - -
-
- @derivedFlow.Title -
-
-
-
- - @if (!derivedItems.Any()) - { -
-
- No items -
-
- @(derivedFlow.EmptyStateMessage ?? "No items to display") -
-
- } - else - { - var statuses = DerivedFlowService.GetItemStatuses(derivedFlow.FieldId, Model.FormData); - @foreach (var (item, itemIndex) in derivedItems.Select((item, i) => (item, i))) - { - var declarationData = DerivedFlowService.GetItemDeclarationData(derivedFlow.FieldId, item.Id, Model.FormData); - var status = statuses.TryGetValue(item.Id, out var s) ? s : "Not signed yet"; - -
-
- @item.DisplayName -
-
- @{ - var statusClass = status == "Signed" ? "govuk-tag govuk-tag--green" : "govuk-tag govuk-tag--blue"; - } - @status -
-
- - @foreach (var page in (derivedFlow.Pages ?? new List()).OrderBy(p => p.PageOrder)) - { - @foreach (var field in page.Fields.OrderBy(f => f.Order)) - { - var fieldValue = declarationData.TryGetValue(field.FieldId, out var v) ? v?.ToString() : string.Empty; - var hasValue = !string.IsNullOrEmpty(fieldValue); - -
-
- @field.Label.Value -
-
- @if (hasValue) - { - var isUploadField = fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - @if (isUploadField) - { - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - catch - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else if (field.Type == "radios" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @Html.Raw(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "select" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @Html.Raw(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "checkboxes" && field.Options != null) - { - var selectedValues = Model.GetFormattedFieldValues(field.FieldId); - var selectedLabels = field.Options - .Where(o => selectedValues.Contains(o.Value)) - .Select(o => o.Label ?? o.Value) - .ToList(); - - if (selectedLabels.Any()) - { -
    - @foreach (var label in selectedLabels) - { -
  • @label
  • - } -
- } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - Not answered - } -
-
- } - } - } - } - } -
-
-
- } - else if (task.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) == true) - { - // Multi-collection flow task: Display all flows in one table - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
-
-

@task.TaskName

- @if (Model.IsApplicationEditable()) - { - - } -
-
-
- @foreach (var (flow, flowIndex) in (task.Summary.Flows ?? new List()).Select((f, i) => (f, i))) - { - var fieldId = flow.FieldId; - Model.FormData.TryGetValue(fieldId, out var raw); - var json = raw?.ToString() ?? "[]"; - - List> items = new(); - try { items = System.Text.Json.JsonSerializer.Deserialize>>(json) ?? new(); } catch { } - - // Add flow header (no gray background, just bold text) -
-
- @flow.Title -
-
- -
-
- - @if (items.Count == 0) - { -
-
- No items added -
-
- Not answered -
-
- } - else - { - @foreach (var (item, itemIndex) in items.Select((item, i) => (item, i))) - { - var itemNumber = itemIndex + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - Model.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - // Use itemTitleBinding to get the proper label (e.g., "Member", "Person") instead of "Item" - var itemLabel = flow.ItemKind ?? "Item"; - var displayTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {itemNumber}"; - - // Add item header (no gray background, just bold text) -
-
- @displayTitle -
-
- -
-
- - // Display each field for this collection item - @foreach (var col in summaryColumns.Where(c => !Model.IsFieldHiddenForItem(c.Field, item))) - { - var rawValue = DisplayHelpers.InterpolateMessage($"{{{col.Field}}}", expandedItem); - var value = rawValue == $"{{{col.Field}}}" ? string.Empty : rawValue; - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - // Temporarily set the item's field value into FormData and use the normal formatter - var originalValue = Model.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - Model.FormData[col.Field] = value; - var formattedValues = Model.GetFormattedFieldValues(col.Field); - if (originalValue != null) - Model.FormData[col.Field] = originalValue; - else - Model.FormData.Remove(col.Field); +

+ @group.GroupName +

- // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\""); - - if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - catch - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - else - { - foreach (var fv in formattedValues) - { -
@Html.Raw(fv)
- } - } - } - } - else - { - Not answered - } -
-
- } - } - } - } -
-
-
- } - else + @foreach (var task in group.Tasks) { - // Regular task: Display page/field data in single summary card - var taskNameHyphenated = task.TaskName.Replace(" ", "-").ToLower(); - -
+

@task.TaskName

- @if (Model.IsApplicationEditable()) + @if (submit?.IsEditable == true) {
- @foreach (var page in (task.Pages ?? new List()).OrderBy(p => p.PageOrder)) + @foreach (var row in task.Rows) { - @foreach (var field in page.Fields.OrderBy(f => f.Order).Where(f => !Model.IsFieldHidden(f.FieldId))) - { - var fieldValue = Model.GetFieldValue(field.FieldId); - var hasValue = Model.HasFieldValue(field.FieldId); - - if ((field.Type == "autocomplete" || field.Type == "complexField" || field.Type == "upload") && hasValue) - { - // Handle multiple autocomplete/complex field values as main header + individual rows - var formattedValues = Model.GetFormattedFieldValues(field.FieldId); - var itemLabel = Model.GetFieldItemLabel(field.FieldId); - var allowMultiple = Model.IsFieldAllowMultiple(field.FieldId); - - // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(fieldValue) && fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - // Show main field header -
-
- @field.Label.Value -
-
- @if (formattedValues.Count == 0) - { - Not answered - } - else if (!allowMultiple) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.FirstOrDefault()) - } - } - catch - { - @Html.Raw(formattedValues.FirstOrDefault()) - } - } - else - { - - var html = RenderAutocompleteSummary(DisplayHelpers.UnsanitiseHtmlInput(fieldValue)); - @Html.Raw(html) - } - } -
-
- - // Show individual items if multiple selection is enabled - @if (allowMultiple && formattedValues.Count > 0) - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var (file, index) in uploadFiles.Select((f, i) => (f, i))) - { -
-
- @itemLabel @(index + 1) -
-
-
- - - -
-
-
- } - } - } - catch - { - // Fallback to original behavior if parsing fails - @foreach (var (value, index) in formattedValues.Select((v, i) => (v, i))) - { -
-
- @itemLabel @(index + 1) -
-
- @Html.Raw(value) -
-
- } - } - } - else - { - @foreach (var (value, index) in formattedValues.Select((v, i) => (v, i))) - { -
-
- @itemLabel @(index + 1) -
-
- @Html.Raw(value) -
-
- } - } - } - } - else - { -
-
- @field.Label.Value -
-
- @if (hasValue) - { - // Check if this is an upload field by looking at the raw value - var isUploadField = !string.IsNullOrEmpty(fieldValue) && fieldValue.StartsWith("[") && fieldValue.Contains("\"id\""); - - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(fieldValue); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - catch - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else if (field.Type == "radios" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @(selectedOption?.Label ?? fieldValue) - } - else if (field.Type == "select" && field.Options != null) - { - var selectedOption = field.Options.FirstOrDefault(o => o.Value == fieldValue); - @(selectedOption?.Label ?? fieldValue) - } - else - { - @Html.Raw(fieldValue.Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
")) - } - } - else - { - Not answered - } -
-
- } - } + @await Html.PartialAsync("FormEngine/_SummaryRow", row) }
@@ -643,19 +72,18 @@
-@if (Model.IsApplicationEditable() && isLeadApplicant) +@if (submit?.ShowSubmitSection == true) {

Submit your @AppTerminology.Singular

- - @if (submitIsDisabled) + @if (submit.SubmitDisabledByConfig) { -

@AppConfig["Layout:SubmitAppDisabled:HelpText"]

+

@submit.DisabledHelpText

You'll see a Submit button here when they're open.

You can still work on your @AppTerminology.Singular and save it.

} - else if (Model.FileValidationBlocksSubmit) + else if (submit.FileValidationBlocksSubmit) {
@@ -663,7 +91,7 @@

You cannot submit until uploaded files pass validation.

    - @foreach (var file in Model.FileValidationBlockingFiles) + @foreach (var file in submit.BlockingFiles) {
  • @file.OriginalFileName @@ -690,7 +118,7 @@

    By submitting this @AppTerminology.Singular you are confirming that, to the best of your knowledge, the details you are providing are correct.

    - @if (ViewContext.HttpContext.Request.Query.ContainsKey("preview")) + @if (submit.IncludePreviewQuery) { } @@ -699,11 +127,9 @@
    } - -
} -else if (Model.IsApplicationEditable() && !isLeadApplicant) +else if (submit?.ShowLeadApplicantInset == true) {
@@ -711,44 +137,3 @@ else if (Model.IsApplicationEditable() && !isLeadApplicant)
} - -@functions { - // Builds HTML matching confirmation page formatting for a single autocomplete selection - private static string RenderAutocompleteSummary(string rawValue) - { - if (string.IsNullOrWhiteSpace(rawValue)) return string.Empty; - try - { - using var doc = JsonDocument.Parse(rawValue); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - - var root = doc.RootElement; - string name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? string.Empty : string.Empty; - string postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("postCode", out var pc2) && pc2.ValueKind == JsonValueKind.String) postcode = pc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.Object) - { - if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) postcode = apc.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postCode", out var apc2) && apc2.ValueKind == JsonValueKind.String) postcode = apc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postalCode", out var apc3) && apc3.ValueKind == JsonValueKind.String) postcode = apc3.GetString() ?? string.Empty; - } - string ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; - string ch = root.TryGetProperty("companiesHouseNumber", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(ch) && root.TryGetProperty("companiesHousenumber", out var c2)) ch = c2.ToString(); - - var sb = new System.Text.StringBuilder(); - if (!string.IsNullOrWhiteSpace(name)) sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); - if (!string.IsNullOrWhiteSpace(postcode)) sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); - if (!string.IsNullOrWhiteSpace(ukprn)) sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); - if (!string.IsNullOrWhiteSpace(ch)) sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(ch)}"); - return sb.ToString(); - } - catch - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - } -} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml index 89bb21e..ce6fcef 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionFlowSummary.cshtml @@ -1,7 +1,6 @@ @model RenderFormModel @{ var task = Model.CurrentTask; - var summary = task?.Summary; } @if (!string.IsNullOrEmpty(Model.SuccessMessage)) @@ -63,13 +62,9 @@

@task?.TaskName

- @if (summary?.Flows != null) + @foreach (var flow in Model.CollectionFlows) { - // Multi-flow mode: render each flow section - @foreach (var flow in summary.Flows) - { - @await Html.PartialAsync("~/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml", new Tuple(Model, flow)) - } + @await Html.PartialAsync("FormEngine/_SingleCollectionFlow", flow) } @if (Model.IsApplicationEditable()) diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml new file mode 100644 index 0000000..f2d17ac --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_CollectionItemRemove.cshtml @@ -0,0 +1,18 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.CollectionItemRemoveViewModel +@using GovUK.Dfe.FlexForms.Web.Extensions +
+ + + + + + + + @Html.RenderLinkConfirmationButton( + buttonText: "Remove", + handler: "RemoveCollectionItem", + displayFields: "itemTitle", + title: Model.ConfirmationTitle, + requiredMessage: Model.RequiredMessage, + buttonId: Model.ButtonId) +
diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml new file mode 100644 index 0000000..0ef88d2 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_FileDownloadButton.cshtml @@ -0,0 +1,21 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryFileLinkViewModel +@if (Model.PageId != null) +{ +
+ + + +
+} +else +{ +
+ + + +
+} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml index befc1e5..45133f7 100644 --- a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SingleCollectionFlow.cshtml @@ -1,490 +1,46 @@ -@using GovUK.Dfe.FlexForms.Web.Extensions -@using GovUK.Dfe.FlexForms.Infrastructure.Services -@using GovUk.Frontend.AspNetCore.TagHelpers -@using System.Text.Json -@using GovUK.Dfe.FlexForms.Web.Utilities -@inject GovUK.Dfe.FlexForms.Application.Interfaces.IComplexFieldConfigurationService ComplexFieldConfigurationService -@model Tuple -@{ - var renderModel = Model.Item1; - var flow = Model.Item2; - - // Read collection from accumulated form data. We store as JSON string under fieldId - renderModel.FormData.TryGetValue(flow.FieldId, out var raw); - var json = raw?.ToString() ?? "[]"; - - List> items = new(); - try { items = System.Text.Json.JsonSerializer.Deserialize>>(json) ?? new(); } catch { } - var itemLabel = flow.ItemKind ?? "Item"; - var itemLabelPlural = flow.ItemKindPlural ?? $"{itemLabel}s"; -} +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.CollectionFlowSectionViewModel
-

@flow.Title

- - @{ - var (hintHtml, hintClass) = MarkdownSafe.RenderHintWithClass(flow.Description); - } +

@Model.Title

- @if (!string.IsNullOrEmpty(flow.Description)) + @if (!string.IsNullOrEmpty(Model.DescriptionHtml)) { -
@Html.Raw(hintHtml)
+
@Html.Raw(Model.DescriptionHtml)
} - @if (items.Any()) + @if (Model.Items.Count > 0) { - @if (flow.TableType?.Equals("list", StringComparison.OrdinalIgnoreCase) == true) + @if (Model.IsListStyle) { - @* List style: Simple govuk-summary-list without cards *@
- @foreach (var (item, index) in items.Select((item, i) => (item, i))) + @foreach (var item in Model.Items) { - var memberNumber = index + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - renderModel.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {memberNumber}"; - - @* Add separator header for each item (except first) *@ - @if (index > 0) - { -
-
- @memberTitle -
-
- -
-
-
- - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
-
-
- } - else + @await Html.PartialAsync("FormEngine/_SummaryRow", item.HeaderRow) + @foreach (var row in item.Rows) { - @* First item header without border *@ -
-
- @memberTitle -
-
- -
-
-
- - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
-
-
- } - - @* Item fields *@ - @foreach (var col in summaryColumns.Where(c => !renderModel.IsFieldHiddenForItem(c.Field, item))) - { - var valueObj = item.TryGetValue(col.Field, out var v) ? v : null; - var value = valueObj is string sv ? sv : - valueObj is System.Text.Json.JsonElement je ? je.ToString() : - (valueObj != null ? System.Text.Json.JsonSerializer.Serialize(valueObj) : string.Empty); - - // Find which page contains this field within this flow - var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); - var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? ""; - // Detect field type for special rendering (upload vs autocomplete) - var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); - var isAutocompleteField = false; - var isUploadFieldByConfig = false; - if (fieldConfig != null && fieldConfig.Type == "complexField" && fieldConfig.ComplexField != null) - { - var cfg = ComplexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); - isAutocompleteField = string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase); - isUploadFieldByConfig = string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase); - } - var checkboxValues = fieldConfig?.Type == "checkboxes" - ? CheckboxValueNormalizer.Normalize(valueObj ?? value) - : Array.Empty(); - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - if (checkboxValues.Any()) - { -
    - @foreach (var option in checkboxValues) - { -
  • @option
  • - } -
- } - else - { - // Temporarily add this item's field data to FormData for formatting - var originalValue = renderModel.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - renderModel.FormData[col.Field] = value; - var formattedValues = renderModel.GetFormattedFieldValues(col.Field); - // Restore original value - if (originalValue != null) - renderModel.FormData[col.Field] = originalValue; - else - renderModel.FormData.Remove(col.Field); - - // Determine if this is an upload field - var isUploadField = isUploadFieldByConfig || (!string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\"")); - // If autocomplete field and current value is empty, try to infer from item values - if (isAutocompleteField && string.IsNullOrEmpty(value)) - { - var inferred = TryFindAutocompleteJsonInItem(item); - if (!string.IsNullOrEmpty(inferred)) - { - value = inferred; - } - } - - @if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else if (isAutocompleteField) - { - var html = RenderAutocompleteSummary(value); - @Html.Raw(html) - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - catch - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - } - } - else - { - Not answered - } -
-
- - Change @col.Label for @memberTitle - -
-
+ @await Html.PartialAsync("FormEngine/_SummaryRow", row) } }
} else { - @* Card style: Current govuk-summary-card approach (default) *@ - @foreach (var (item, index) in items.Select((item, i) => (item, i))) + @foreach (var item in Model.Items) { - var memberNumber = index + 1; - var expandedItem = DisplayHelpers.ExpandEncodedJson(item); - var summaryColumns = flow.SummaryColumns ?? new List(); - renderModel.EnsureItemFieldVisibility(item, summaryColumns.Select(c => c.Field)); - var memberTitle = !string.IsNullOrEmpty(flow.ItemTitleBinding) - ? DisplayHelpers.InterpolateMessage($"{{{flow.ItemTitleBinding}}}", expandedItem) - : $"{itemLabel} {memberNumber}"; -
-

@memberTitle

+

@item.Title

  • -
    - - - - - - - - @Html.RenderLinkConfirmationButton( - buttonText: "Remove", - handler: "RemoveCollectionItem", - displayFields: "itemTitle", - title: $"Are you sure you want to remove this {itemLabel.ToLower()}?", - requiredMessage: $"Select yes if you are sure you want to remove this {itemLabel.ToLower()}", - buttonId: $"remove-flow-item-@memberNumber") -
    + @await Html.PartialAsync("FormEngine/_CollectionItemRemove", item.Remove)
-
-
- @foreach (var col in summaryColumns.Where(c => !renderModel.IsFieldHiddenForItem(c.Field, item))) +
+
+ @foreach (var row in item.Rows) { - var valueObj = item.TryGetValue(col.Field, out var v) ? v : null; - var value = valueObj is string sv ? sv : - valueObj is System.Text.Json.JsonElement je ? je.ToString() : - (valueObj != null ? System.Text.Json.JsonSerializer.Serialize(valueObj) : string.Empty); - // Find which page contains this field within this flow - var targetPage = flow.Pages?.FirstOrDefault(p => p.Fields.Any(f => f.FieldId == col.Field)); - var pageId = targetPage?.PageId ?? flow.Pages?.FirstOrDefault()?.PageId ?? ""; - // Detect field type for special rendering (upload vs autocomplete) - var fieldConfig = targetPage?.Fields.FirstOrDefault(f => f.FieldId == col.Field); - var isAutocompleteField = false; - var isUploadFieldByConfig = false; - if (fieldConfig != null && fieldConfig.Type == "complexField" && fieldConfig.ComplexField != null) - { - var cfg = ComplexFieldConfigurationService.GetConfiguration(fieldConfig.ComplexField.Id); - isAutocompleteField = string.Equals(cfg.FieldType, "autocomplete", StringComparison.OrdinalIgnoreCase); - isUploadFieldByConfig = string.Equals(cfg.FieldType, "upload", StringComparison.OrdinalIgnoreCase); - } - // If autocomplete field and current value is empty, try to infer from item values - if (isAutocompleteField && string.IsNullOrEmpty(value)) - { - var inferred = TryFindAutocompleteJsonInItem(item); - if (!string.IsNullOrEmpty(inferred)) - { - value = inferred; - } - } - -
-
- @col.Label -
-
- @if (!string.IsNullOrEmpty(value)) - { - // Temporarily add this item's field data to FormData for formatting - var originalValue = renderModel.FormData.TryGetValue(col.Field, out var orig) ? orig : null; - renderModel.FormData[col.Field] = value; - var formattedValues = renderModel.GetFormattedFieldValues(col.Field); - // Restore original value - if (originalValue != null) - renderModel.FormData[col.Field] = originalValue; - else - renderModel.FormData.Remove(col.Field); - - // Determine if this is an upload field - var isUploadField = isUploadFieldByConfig || (!string.IsNullOrEmpty(value) && value.StartsWith("[") && value.Contains("\"id\"")); - - - @if (formattedValues.Count == 0) - { - Not answered - } - else if (formattedValues.Count == 1) - { - @if (isUploadField) - { - // For upload fields, render as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - var file = uploadFiles.First(); -
- - - -
- } - else - { - @Html.Raw(formattedValues.First()) - } - } - catch - { - @Html.Raw(formattedValues.First()) - } - } - else if (isAutocompleteField) - { - var html = RenderAutocompleteSummary(DisplayHelpers.UnsanitiseHtmlInput(value)); - @Html.Raw(html) - } - else - { - @Html.Raw(formattedValues.First()) - } - } - else - { - @if (isUploadField) - { - // For upload fields with multiple files, render each as download link - try - { - var uploadFiles = System.Text.Json.JsonSerializer.Deserialize>(value); - // CRITICAL: Filter out infected files using Redis blacklist - uploadFiles = renderModel.FilterInfectedFilesFromList(uploadFiles ?? new List()); - if (uploadFiles != null && uploadFiles.Any()) - { - @foreach (var file in uploadFiles) - { -
-
- - - -
-
- } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - catch - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - else - { - - @foreach (var formattedValue in formattedValues) - { -
@Html.Raw(formattedValue)
- } - } - } - } - else - { - Not answered - } -
-
- - Change @col.Label for @memberTitle - -
-
+ @await Html.PartialAsync("FormEngine/_SummaryRow", row) }
@@ -494,89 +50,15 @@ } else { - var noItemsHintId = flow.FlowId + "-no-items-added-hint"; - -
- No @(itemLabelPlural.ToLowerInvariant()) have been added. +
+ No @(Model.ItemKindPlural.ToLowerInvariant()) have been added.
} - @{ - var addItemButtonId = flow.FlowId + "-add-item"; - } - - @{ - var canAddMore = !flow.MaxItems.HasValue || items.Count < flow.MaxItems.Value; - } - @if (canAddMore) + @if (Model.CanAddMore) { }
-@functions{ - // Builds HTML matching confirmation page formatting for a single autocomplete selection - private static string RenderAutocompleteSummary(string rawValue) - { - if (string.IsNullOrWhiteSpace(rawValue)) return string.Empty; - try - { - using var doc = JsonDocument.Parse(rawValue); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - - var root = doc.RootElement; - string name = root.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? string.Empty : string.Empty; - string postcode = root.TryGetProperty("postcode", out var pc) && pc.ValueKind == JsonValueKind.String ? pc.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("postCode", out var pc2) && pc2.ValueKind == JsonValueKind.String) postcode = pc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.Object) - { - if (addr.TryGetProperty("postcode", out var apc) && apc.ValueKind == JsonValueKind.String) postcode = apc.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postCode", out var apc2) && apc2.ValueKind == JsonValueKind.String) postcode = apc2.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(postcode) && addr.TryGetProperty("postalCode", out var apc3) && apc3.ValueKind == JsonValueKind.String) postcode = apc3.GetString() ?? string.Empty; - } - string ukprn = root.TryGetProperty("ukprn", out var u) ? u.ToString() : string.Empty; - string ch = root.TryGetProperty("companiesHouseNumber", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrWhiteSpace(ch) && root.TryGetProperty("companiesHousenumber", out var c2)) ch = c2.ToString(); - - var sb = new System.Text.StringBuilder(); - if (!string.IsNullOrWhiteSpace(name)) sb.Append($"{System.Net.WebUtility.HtmlEncode(name)}"); - if (!string.IsNullOrWhiteSpace(postcode)) sb.Append($"
Postcode: {System.Net.WebUtility.HtmlEncode(postcode)}"); - if (!string.IsNullOrWhiteSpace(ukprn)) sb.Append($"
UKPRN: {System.Net.WebUtility.HtmlEncode(ukprn)}"); - if (!string.IsNullOrWhiteSpace(ch)) sb.Append($"
Companies house number: {System.Net.WebUtility.HtmlEncode(ch)}"); - return sb.ToString(); - } - catch - { - return System.Net.WebUtility.HtmlEncode(rawValue); - } - } - - // Heuristic: find a likely autocomplete JSON object in the item's values - private static string TryFindAutocompleteJsonInItem(Dictionary item) - { - foreach (var kv in item) - { - var s = kv.Value?.ToString(); - if (string.IsNullOrWhiteSpace(s)) continue; - try - { - using var doc = JsonDocument.Parse(s); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - if (doc.RootElement.TryGetProperty("name", out _) || - doc.RootElement.TryGetProperty("ukprn", out _) || - doc.RootElement.TryGetProperty("companiesHouseNumber", out _)) - { - return s; - } - } - } - catch { } - } - return string.Empty; - } -} diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml new file mode 100644 index 0000000..e7b8aa3 --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryRow.cshtml @@ -0,0 +1,29 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryRowViewModel +@{ + var rowStyle = Model.ShowSeparator + ? "border-top: 2px solid #d1d1d1; margin-top: 15px; padding-top: 15px;" + : null; + var keyStyle = Model.KeyIsBold ? "font-weight: bold;" : null; +} +
+
+ @Model.Key +
+
+ @await Html.PartialAsync("FormEngine/_SummaryValue", Model.Value) +
+ @if (Model.Remove != null) + { +
+ @await Html.PartialAsync("FormEngine/_CollectionItemRemove", Model.Remove) +
+ } + else if (Model.ChangeUrl != null) + { +
+ + Change @Model.ChangeHiddenText + +
+ } +
diff --git a/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml new file mode 100644 index 0000000..a2fb7ba --- /dev/null +++ b/src/GovUK.Dfe.FlexForms.Web/Views/Shared/FormEngine/_SummaryValue.cshtml @@ -0,0 +1,54 @@ +@model GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine.SummaryValueViewModel +@using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine +@switch (Model.Kind) +{ + case SummaryDisplayKind.NotAnswered: + Not answered + break; + case SummaryDisplayKind.Empty: + break; + case SummaryDisplayKind.Html: + @Html.Raw(Model.Html) + break; + case SummaryDisplayKind.Text: + @Model.Text + break; + case SummaryDisplayKind.HtmlList: + @foreach (var item in Model.HtmlItems) + { +
@Html.Raw(item)
+ } + break; + case SummaryDisplayKind.Checkboxes: +
    + @foreach (var option in Model.Checkboxes) + { +
  • @option
  • + } +
+ break; + case SummaryDisplayKind.AutocompleteHtml: + @Html.Raw(Model.Html) + break; + case SummaryDisplayKind.StatusTag: + { + var statusClass = Model.StatusIsSigned ? "govuk-tag govuk-tag--green" : "govuk-tag govuk-tag--blue"; + @Model.StatusText + break; + } + case SummaryDisplayKind.UploadFiles: + @foreach (var file in Model.Files) + { + if (Model.WrapFilesInDivs) + { +
+ @await Html.PartialAsync("FormEngine/_FileDownloadButton", file) +
+ } + else + { + @await Html.PartialAsync("FormEngine/_FileDownloadButton", file) + } + } + break; +} diff --git a/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs new file mode 100644 index 0000000..d32d045 --- /dev/null +++ b/src/Tests/GovUK.Dfe.FlexForms.Web.UnitTests/ViewModels/FormEngine/AutocompleteSummaryFormatterTests.cs @@ -0,0 +1,65 @@ +using GovUK.Dfe.FlexForms.Web.ViewModels.FormEngine; + +namespace GovUK.Dfe.FlexForms.Web.UnitTests.ViewModels.FormEngine; + +public class AutocompleteSummaryFormatterTests +{ + [Fact] + public void Render_returns_empty_for_blank_input() + { + Assert.Equal(string.Empty, AutocompleteSummaryFormatter.Render(null)); + Assert.Equal(string.Empty, AutocompleteSummaryFormatter.Render(" ")); + } + + [Fact] + public void Render_formats_name_postcode_ukprn_and_companies_house() + { + var json = """ + { + "name": "Contoso Trust", + "postcode": "SW1A 1AA", + "ukprn": "12345678", + "companiesHouseNumber": "AB123456" + } + """; + + var html = AutocompleteSummaryFormatter.Render(json); + + Assert.Contains("govuk-!-font-weight-bold", html); + Assert.Contains("Contoso Trust", html); + Assert.Contains("Postcode: SW1A 1AA", html); + Assert.Contains("UKPRN: 12345678", html); + Assert.Contains("Companies house number: AB123456", html); + } + + [Fact] + public void Render_reads_nested_address_postcode() + { + var json = """{"name":"School","address":{"postalCode":"M1 1AA"}}"""; + + var html = AutocompleteSummaryFormatter.Render(json); + + Assert.Contains("Postcode: M1 1AA", html); + } + + [Fact] + public void Render_html_encodes_non_json_and_invalid_json() + { + Assert.Equal("<script>", AutocompleteSummaryFormatter.Render("