From 6d6ff172f5fe2b6c164eacfb238ce757dc133c17 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 17 Oct 2026 10:19:52 +0100 Subject: [PATCH 1/3] Harden file upload acceptance --- .../DummyFileProcessorDomainService.cs | 7 + .../FileProcessorDomainServiceTests.cs | 4 + .../Managers/FileProcessorManager.cs | 22 +++ .../Managers/IFileProcessorManager.cs | 2 + .../Services/FileProcessorDomainService.cs | 141 +++++++++++++----- .../FileImportLogAggregate.cs | 21 ++- .../Bootstrapper/MiddlewareRegistry.cs | 10 +- FileProcessor/Common/UploadFileSettings.cs | 51 +++++++ FileProcessor/Handlers/FileHandlers.cs | 43 +++++- FileProcessor/Program.cs | 3 +- FileProcessor/appsettings.json | 2 + 11 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 FileProcessor/Common/UploadFileSettings.cs diff --git a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs index 7637ff3..906f089 100644 --- a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs @@ -29,6 +29,13 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran } public class DummyFileProcessorManager : IFileProcessorManager { + public Task ValidateUploadReferences(Guid estateId, + Guid merchantId, + Guid userId, + CancellationToken cancellationToken) { + return Task.FromResult(Result.Success()); + } + public async Task>> GetAllFileProfiles(CancellationToken cancellationToken) { return Result.Success(); } diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index 4bde44b..8fad531 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -60,6 +60,10 @@ public FileProcessorDomainServiceTests() Logger.Initialise(NullLogger.Instance); this.FileProcessorManager = new IFileProcessorManagerImposter(); + this.FileProcessorManager.ValidateUploadReferences(Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()).ReturnsAsync(Result.Success()); this.FileProcessorManager.GetFileProfileVersion(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); diff --git a/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs b/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs index 3d740c7..0382f16 100644 --- a/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs +++ b/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs @@ -96,6 +96,28 @@ private async Task GetContext(Guid estateId) return resolvedContext.Context; } + public async Task ValidateUploadReferences(Guid estateId, + Guid merchantId, + Guid userId, + CancellationToken cancellationToken) + { + if (estateId == Guid.Empty || merchantId == Guid.Empty || userId == Guid.Empty) + return Result.Invalid("Estate, merchant, and user identifiers are required for file upload"); + + EstateManagementContext context = await this.GetContext(estateId); + Merchant merchant = await context.Merchants + .SingleOrDefaultAsync(m => m.MerchantId == merchantId, cancellationToken); + if (merchant == null || merchant.EstateId != estateId) + return Result.NotFound($"Merchant [{merchantId}] was not found for estate [{estateId}]"); + + EstateSecurityUser user = await context.EstateSecurityUsers + .SingleOrDefaultAsync(u => u.SecurityUserId == userId, cancellationToken); + if (user == null) + return Result.NotFound($"User [{userId}] was not found for estate [{estateId}]"); + + return Result.Success(); + } + public async Task> GetFileImportLogs(Guid estateId, DateOnly startDate, diff --git a/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs b/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs index aaab524..2fb52eb 100644 --- a/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs +++ b/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs @@ -34,6 +34,8 @@ Task> GetFileProfileVersion(Guid fileProfileId, Task> GetFile(Guid fileId, Guid estateId, CancellationToken cancellationToken); + Task ValidateUploadReferences(Guid estateId, Guid merchantId, Guid userId, CancellationToken cancellationToken); + #endregion } } diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 28f9128..5da7fa9 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -167,18 +167,73 @@ public async Task UploadFile(FileCommands.UploadFileCommand command, if (validateResult.IsFailed) return validateResult; - // This will now create the import log and add an event for the file being uploaded + // The import log is created only after validation and duplicate checks succeed. Guid importLogId = Helpers.CalculateFileImportLogAggregateId(importLogDateTime.Date, command.EstateId); Result result = await ApplyFileImportLogUpdates(async (fileImportLogAggregate) => { - if (fileImportLogAggregate.IsCreated == false) + IFileInfo file = this.FileSystem.FileInfo.New(command.FilePath); + if (file.Exists == false) { + return Result.NotFound($"File {file.FullName} not found"); + } + + String originalName = String.IsNullOrWhiteSpace(command.OriginalFileName) + ? file.Name + : command.OriginalFileName; + + // Read the original bytes so the server validates the same content hash as the uploader. + Byte[] fileContent; + await using (Stream fs = file.Open(FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) { + using MemoryStream memoryStream = new(); + await fs.CopyToAsync(memoryStream, cancellationToken); + fileContent = memoryStream.ToArray(); + } + Guid generatedFileId = CreateGuidFromFileData(fileContent); + + if (generatedFileId != command.FileId) { + return Result.Invalid("File content does not match the file id provided"); + } + + Result existingFileResult = await this.FileAggregateRepository.GetLatestVersion(command.FileId, cancellationToken); + if (existingFileResult?.IsSuccess == true && existingFileResult.Data?.IsCreated == true) { - // First file of the day so create - Result result = fileImportLogAggregate.CreateImportLog(command.EstateId, importLogDateTime); - if (result.IsFailed) { - return result; + FileDetails existingFile = existingFileResult.Data.GetFile(); + if (existingFile.EstateId != command.EstateId || + existingFile.MerchantId != command.MerchantId || + existingFile.UserId != command.UserId || + existingFile.FileProfileId != command.FileProfileId) + { + return Result.Invalid($"File Id [{command.FileId}] is already registered with different upload details"); } + + this.DeleteDuplicateUploadFile(command.FilePath); + return Result.Success(); } + if (existingFileResult?.IsFailed == true && existingFileResult.Status != ResultStatus.NotFound) + return ResultHelpers.CreateFailure(existingFileResult); + + // An identical file already on the current import log is accepted without appending another event. + Boolean fileAlreadyRegistered = fileImportLogAggregate.ContainsFile(command.FileId); + if (fileAlreadyRegistered) + { + Result duplicateResult = fileImportLogAggregate.AddImportedFile(new AddImportedFileRequest(command.FileId, + command.MerchantId, + command.UserId, + command.FileProfileId, + originalName, + command.FilePath, + command.FileUploadedDateTime)); + if (duplicateResult.IsSuccess) + this.DeleteDuplicateUploadFile(command.FilePath); + + return duplicateResult; + } + + Result referencesResult = await this.FileProcessorManager.ValidateUploadReferences(command.EstateId, + command.MerchantId, + command.UserId, + cancellationToken); + if (referencesResult.IsFailed) + return ResultHelpers.CreateFailure(referencesResult); Result getFileProfileResult = await this.FileProcessorManager.GetFileProfile(command.FileProfileId, cancellationToken); if (getFileProfileResult.IsFailed) @@ -191,40 +246,21 @@ public async Task UploadFile(FileCommands.UploadFileCommand command, ? getFileProfileResult.Data.FileProfileId : getFileProfileResult.Data.FileProfileVersionId; - IFileInfo file = this.FileSystem.FileInfo.New(command.FilePath); - if (file.Exists == false) { - return Result.NotFound($"File {file.FullName} not found"); - } - - String originalName = String.IsNullOrWhiteSpace(command.OriginalFileName) - ? file.Name - : command.OriginalFileName; - - // Read the file data - String fileContent = null; - //Open file for Read\Write - await using (Stream fs = file.Open(FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) { - //Create object of StreamReader by passing FileStream object on which it needs to operates on - using (StreamReader sr = new StreamReader(fs)) { - //Use ReadToEnd method to read all the content from file - fileContent = await sr.ReadToEndAsync(cancellationToken); - } + if (fileImportLogAggregate.IsCreated == false) + { + Result createResult = fileImportLogAggregate.CreateImportLog(command.EstateId, importLogDateTime); + if (createResult.IsFailed) + return createResult; } - Guid generatedFileId = CreateGuidFromFileData(fileContent); - if (generatedFileId != command.FileId) { - return Result.Invalid("File content does not match the file id provided"); - } - - // Update Import log aggregate - Result stateResult= fileImportLogAggregate.AddImportedFile(new AddImportedFileRequest(command.FileId, - command.MerchantId, - command.UserId, - command.FileProfileId, - originalName, - command.FilePath, - command.FileUploadedDateTime, - fileProfileVersionId)); + Result stateResult = fileImportLogAggregate.AddImportedFile(new AddImportedFileRequest(command.FileId, + command.MerchantId, + command.UserId, + command.FileProfileId, + originalName, + command.FilePath, + command.FileUploadedDateTime, + fileProfileVersionId)); if (stateResult.IsFailed) return stateResult; @@ -235,11 +271,16 @@ public async Task UploadFile(FileCommands.UploadFileCommand command, } public static Guid CreateGuidFromFileData(String fileContents) + { + return CreateGuidFromFileData(Encoding.UTF8.GetBytes(fileContents)); + } + + public static Guid CreateGuidFromFileData(Byte[] fileContents) { using (SHA256 sha256Hash = SHA256.Create()) { //Generate hash from the key - Byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(fileContents)); + Byte[] bytes = sha256Hash.ComputeHash(fileContents); Byte[] j = bytes.Skip(Math.Max(0, bytes.Count() - 16)).ToArray(); //Take last 16 @@ -248,6 +289,14 @@ public static Guid CreateGuidFromFileData(String fileContents) } } private Result ValidateRequest(FileCommands.UploadFileCommand command) { + if (command.EstateId == Guid.Empty) { + return Result.Invalid("No Estate Id provided with file upload"); + } + + if (command.FileId == Guid.Empty) { + return Result.Invalid("No File Id provided with file upload"); + } + if (command.UserId == Guid.Empty) { return Result.Invalid("No User Id provided with file upload"); } @@ -263,6 +312,20 @@ private Result ValidateRequest(FileCommands.UploadFileCommand command) { return Result.Success(); } + private void DeleteDuplicateUploadFile(String filePath) + { + try + { + IFileInfo file = this.FileSystem.FileInfo.New(filePath); + if (file.Exists) + file.Delete(); + } + catch (Exception exception) + { + Logger.LogWarning($"Failed to remove duplicate temporary upload file [{filePath}]. Error [{exception.Message}]"); + } + } + public async Task ProcessUploadedFile(FileCommands.ProcessUploadedFileCommand command, CancellationToken cancellationToken) { Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => { diff --git a/FileProcessor.FileImportLogAggregate/FileImportLogAggregate.cs b/FileProcessor.FileImportLogAggregate/FileImportLogAggregate.cs index b7267cf..78af93f 100644 --- a/FileProcessor.FileImportLogAggregate/FileImportLogAggregate.cs +++ b/FileProcessor.FileImportLogAggregate/FileImportLogAggregate.cs @@ -52,9 +52,18 @@ public static Result AddImportedFile(this FileImportLogAggregate aggregate, AddI return Result.Invalid("Import log has not been created"); } - if (aggregate.Files.Any(f => f.FileId == request.FileId)) + ImportLogFile existingFile = aggregate.Files.SingleOrDefault(f => f.FileId == request.FileId); + if (existingFile != null) { - return Result.Invalid($"Duplicate file {request.OriginalFileName} detected File Id [{request.FileId}]"); + if (existingFile.EstateId == aggregate.EstateId && + existingFile.MerchantId == request.MerchantId && + existingFile.UserId == request.UserId && + existingFile.FileProfileId == request.FileProfileId) + { + return Result.Success(); + } + + return Result.Invalid($"File Id [{request.FileId}] is already registered with different upload details"); } FileAddedToImportLogEvent fileAddedToImportLogEvent = @@ -74,6 +83,11 @@ public static Result AddImportedFile(this FileImportLogAggregate aggregate, AddI return Result.Success(); } + public static Boolean ContainsFile(this FileImportLogAggregate aggregate, Guid fileId) + { + return aggregate.Files.Any(file => file.FileId == fileId); + } + public static void PlayEvent(this FileImportLogAggregate aggregate, ImportLogCreatedEvent domainEvent) { aggregate.IsCreated = true; @@ -92,7 +106,8 @@ public static void PlayEvent(this FileImportLogAggregate aggregate, FileAddedToI MerchantId = domainEvent.MerchantId, FilePath = domainEvent.FilePath, OriginalFileName = domainEvent.OriginalFileName, - UserId = domainEvent.UserId + UserId = domainEvent.UserId, + UploadedDateTime = domainEvent.FileUploadedDateTime }); } diff --git a/FileProcessor/Bootstrapper/MiddlewareRegistry.cs b/FileProcessor/Bootstrapper/MiddlewareRegistry.cs index 0839505..2c56b4e 100644 --- a/FileProcessor/Bootstrapper/MiddlewareRegistry.cs +++ b/FileProcessor/Bootstrapper/MiddlewareRegistry.cs @@ -4,6 +4,7 @@ namespace FileProcessor.Bootstrapper { + using FileProcessor.Common; using Lamar; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; @@ -113,10 +114,9 @@ public MiddlewareRegistry() this.Configure(options => { - // Allow very large values (adjust to what you need) - options.ValueLengthLimit = int.MaxValue; // form value length - options.MultipartBodyLengthLimit = long.MaxValue; // multipart body length - options.MemoryBufferThreshold = int.MaxValue; // buffer threshold + options.ValueLengthLimit = 16 * 1024; + options.MultipartBodyLengthLimit = UploadFileSettings.GetMaximumMultipartRequestSizeBytes(); + options.MemoryBufferThreshold = 64 * 1024; }); this.AddHealthMonitoringRegistration(Startup.Configuration); @@ -161,4 +161,4 @@ public static void ConfigureMinimalApi(JsonSerializerOptions serializerOptions) serializerOptions.Converters.Add(new DateTimeSpaceConverter()); } } -} \ No newline at end of file +} diff --git a/FileProcessor/Common/UploadFileSettings.cs b/FileProcessor/Common/UploadFileSettings.cs new file mode 100644 index 0000000..b96d799 --- /dev/null +++ b/FileProcessor/Common/UploadFileSettings.cs @@ -0,0 +1,51 @@ +using System; +using System.Globalization; +using Shared.General; + +namespace FileProcessor.Common; + +public static class UploadFileSettings +{ + private const Int64 DefaultMaximumFileSizeBytes = 10 * 1024 * 1024; + private const Int64 MaximumMultipartOverheadBytes = 1024 * 1024; + private const String DefaultAllowedFileExtensions = ".csv,.txt"; + + public static Int64 GetMaximumFileSizeBytes() + { + String configuredValue = ConfigurationReader.GetValueOrDefault("AppSettings", "MaximumUploadFileSizeBytes", DefaultMaximumFileSizeBytes.ToString(CultureInfo.InvariantCulture)); + + if (Int64.TryParse(configuredValue, NumberStyles.None, CultureInfo.InvariantCulture, out Int64 maximumFileSizeBytes) == false || maximumFileSizeBytes <= 0) + throw new InvalidOperationException("AppSettings:MaximumUploadFileSizeBytes must be a positive integer when configured"); + + return maximumFileSizeBytes; + } + + public static Int64 GetMaximumMultipartRequestSizeBytes() + { + try + { + return checked(GetMaximumFileSizeBytes() + MaximumMultipartOverheadBytes); + } + catch (OverflowException exception) + { + throw new InvalidOperationException("AppSettings:MaximumUploadFileSizeBytes is too large", exception); + } + } + + public static String[] GetAllowedFileExtensions() + { + String value = ConfigurationReader.GetValueOrDefault("AppSettings", "AllowedUploadFileExtensions", DefaultAllowedFileExtensions); + + String[] extensions = value.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (String extension in extensions) + { + if (extension.StartsWith('.') == false || extension.Length < 2) + throw new InvalidOperationException("AppSettings:AllowedUploadFileExtensions must contain comma-separated extensions beginning with a dot"); + } + + if (extensions.Length == 0) + throw new InvalidOperationException("AppSettings:AllowedUploadFileExtensions must contain at least one extension"); + + return extensions; + } +} diff --git a/FileProcessor/Handlers/FileHandlers.cs b/FileProcessor/Handlers/FileHandlers.cs index 58e10be..bba8b42 100644 --- a/FileProcessor/Handlers/FileHandlers.cs +++ b/FileProcessor/Handlers/FileHandlers.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Linq; -using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using FileProcessor.BusinessLogic.Requests; @@ -25,10 +24,25 @@ public static async Task UploadFile(IMediator mediator, [FromForm] IFormCollection formCollection, CancellationToken cancellationToken) { - IFormFile file = formCollection.Files.First(); + if (formCollection?.Files == null || formCollection.Files.Count != 1) + return Results.BadRequest("Exactly one file must be supplied."); + + IFormFile file = formCollection.Files[0]; + if (file.Length <= 0) + return Results.BadRequest("The uploaded file must not be empty."); + + if (file.Length > UploadFileSettings.GetMaximumFileSizeBytes()) + return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); + + String fileName = SanitizeFileName(file.FileName); + if (String.IsNullOrWhiteSpace(fileName)) + return Results.BadRequest("The uploaded file must have a valid filename."); + + String fileExtension = Path.GetExtension(fileName); + if (UploadFileSettings.GetAllowedFileExtensions().Contains(fileExtension, StringComparer.OrdinalIgnoreCase) == false) + return Results.BadRequest($"Files with extension [{fileExtension}] are not supported."); string temporaryFileLocation = ConfigurationReader.GetValue("AppSettings", "TemporaryFileLocation"); - string fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); string temporaryFileName = Guid.NewGuid().ToString("N"); string fullPath = Path.Combine(temporaryFileLocation, temporaryFileName); bool uploadAccepted = false; @@ -63,6 +77,9 @@ public static async Task UploadFile(IMediator mediator, Shared.Logger.Logger.LogDebug($"Month is {request.UploadDateTime.Month}"); Shared.Logger.Logger.LogDebug($"Year is {request.UploadDateTime.Year}"); + if (result.IsSuccess) + return Results.Accepted(value: request.FileId); + return ResponseFactory.FromResult(result, r => r); } finally @@ -82,6 +99,26 @@ public static async Task UploadFile(IMediator mediator, } } + private static String SanitizeFileName(String fileName) + { + if (String.IsNullOrWhiteSpace(fileName)) + return String.Empty; + + String name = Path.GetFileName(fileName.Replace('\\', '/')); + Char[] invalidCharacters = Path.GetInvalidFileNameChars() + .Concat(new[] { '<', '>', ':', '"', '|', '?', '*' }) + .ToArray(); + String sanitizedName = new(name.Where(character => Char.IsControl(character) == false && invalidCharacters.Contains(character) == false).ToArray()); + sanitizedName = sanitizedName.Trim().TrimEnd('.'); + + if (sanitizedName.Length <= 255) + return sanitizedName; + + String extension = Path.GetExtension(sanitizedName); + Int32 nameLength = Math.Max(1, 255 - extension.Length); + return sanitizedName[..nameLength] + extension; + } + public static async Task GetFile(IMediator mediator, [FromRoute] Guid fileId, [FromQuery] Guid estateId, diff --git a/FileProcessor/Program.cs b/FileProcessor/Program.cs index ef4dca3..b50a377 100644 --- a/FileProcessor/Program.cs +++ b/FileProcessor/Program.cs @@ -126,8 +126,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) webBuilder.UseKestrel(); webBuilder.ConfigureKestrel(serverOptions => { - // Remove Kestrel max request body size limit (set to null -> no limit) - serverOptions.Limits.MaxRequestBodySize = null; + serverOptions.Limits.MaxRequestBodySize = Common.UploadFileSettings.GetMaximumMultipartRequestSizeBytes(); }); }); diff --git a/FileProcessor/appsettings.json b/FileProcessor/appsettings.json index f386279..303e723 100644 --- a/FileProcessor/appsettings.json +++ b/FileProcessor/appsettings.json @@ -29,6 +29,8 @@ ] }, "TemporaryFileLocation": "/home/txnproc/bulkfiles/temporary", + "MaximumUploadFileSizeBytes": 10485760, + "AllowedUploadFileExtensions": ".csv,.txt", "MaximumDetailRecordCount": 1000 }, "HealthMonitoring": { From e0d1d8860425eaaa14314b8f07b0f5ba63d932dc Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 17 Oct 2026 10:27:26 +0100 Subject: [PATCH 2/3] Update upload idempotency expectations --- FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs | 2 +- .../FileImportLogAggregateTests.cs | 6 +++--- .../Features/ProcessTopupCSV.feature | 2 +- .../Features/ProcessVoucherCSV.feature | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs b/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs index 9bf39f5..63aa666 100644 --- a/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileHandlersTests.cs @@ -138,7 +138,7 @@ public async Task UploadFile_DoesNotUseClientFilenameCharactersInTemporaryPath() IMediatorImposter mediator = new(); mediator.Send(Arg>.Any(), Arg.Any()).ReturnsAsync(Result.Success()); - await FileHandlers.UploadFile(mediator.Instance(), CreateUploadRequest(), CreateFormCollection("invoice.csv:payload"), CancellationToken.None); + await FileHandlers.UploadFile(mediator.Instance(), CreateUploadRequest(), CreateFormCollection("invoice:payload.csv"), CancellationToken.None); string storedFileName = Path.GetFileName(Directory.GetFiles(temporaryDirectory).Single()); storedFileName.ShouldNotContain(":"); diff --git a/FileProcessor.FileImportLogAggregate.Tests/FileImportLogAggregateTests.cs b/FileProcessor.FileImportLogAggregate.Tests/FileImportLogAggregateTests.cs index 7f81d6d..66507eb 100644 --- a/FileProcessor.FileImportLogAggregate.Tests/FileImportLogAggregateTests.cs +++ b/FileProcessor.FileImportLogAggregate.Tests/FileImportLogAggregateTests.cs @@ -98,14 +98,14 @@ public void FileImportLogAggregate_AddImportedFile_ImportLogNotCreated_ErrorThro } [Fact] - public void FileImportLogAggregate_AddImportedFile_DuplicateFileId_ErrorThrown() + public void FileImportLogAggregate_AddImportedFile_DuplicateFileId_IsAcceptedIdempotently() { FileImportLogAggregate fileImportLogAggregate = FileImportLogAggregate.Create(TestData.FileImportLogId); fileImportLogAggregate.CreateImportLog(TestData.EstateId, TestData.ImportLogDateTime); fileImportLogAggregate.AddImportedFile(TestData.FileId, TestData.MerchantId, TestData.UserId, TestData.FileProfileId, TestData.OriginalFileName, TestData.FilePath, TestData.FileUploadedDateTime); Result result = fileImportLogAggregate.AddImportedFile(TestData.FileId, TestData.MerchantId, TestData.UserId, TestData.FileProfileId, TestData.OriginalFileName, TestData.FilePath, TestData.FileUploadedDateTime); - result.IsFailed.ShouldBeTrue(); - result.Status.ShouldBe(ResultStatus.Invalid); + result.IsSuccess.ShouldBeTrue(); + fileImportLogAggregate.GetFileImportLog().Files.Count.ShouldBe(1); } } } diff --git a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature index 50daf34..d978bc7 100644 --- a/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature +++ b/FileProcessor.IntegrationTests/Features/ProcessTopupCSV.feature @@ -143,7 +143,7 @@ Scenario: Process Duplicate Safaricom Topup File with 1 detail row | D | 07777777775 | 100 | | D | 07777777776 | 200 | | T | 1 | | - And I upload this file for processing an error should be returned indicating the file is a duplicate + And I upload this file for processing | EstateName | MerchantName | FileProfileId | UserId | | Test Estate 1 | Test Merchant 1 | B2A59ABF-293D-4A6B-B81B-7007503C3476 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | diff --git a/FileProcessor.IntegrationTests/Features/ProcessVoucherCSV.feature b/FileProcessor.IntegrationTests/Features/ProcessVoucherCSV.feature index dbcd15c..a7dccb3 100644 --- a/FileProcessor.IntegrationTests/Features/ProcessVoucherCSV.feature +++ b/FileProcessor.IntegrationTests/Features/ProcessVoucherCSV.feature @@ -157,6 +157,6 @@ Scenario: Process Duplicate Voucher Topup File with 1 detail row | D | Hospital 1 | testrecipient1@recipient.com | 20 | | T | 1 | | | - And I upload this file for processing an error should be returned indicating the file is a duplicate + And I upload this file for processing | EstateName | MerchantName | FileProfileId | UserId | | Test Estate 1 | Test Merchant 1 | 8806EDBC-3ED6-406B-9E5F-A9078356BE99 | ABA59ABF-293D-4A6B-B81B-7007503C3476 | From 58bfd2af3faf9492cb0680f3f7d43f29f61ce1e7 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 17 Oct 2026 14:22:46 +0100 Subject: [PATCH 3/3] Remove upload reference existence validation --- .../DummyFileProcessorDomainService.cs | 7 ------ .../FileProcessorDomainServiceTests.cs | 4 ---- .../Managers/FileProcessorManager.cs | 23 ------------------- .../Managers/IFileProcessorManager.cs | 2 -- .../Services/FileProcessorDomainService.cs | 7 ------ 5 files changed, 43 deletions(-) diff --git a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs index 906f089..7637ff3 100644 --- a/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic.Tests/DummyFileProcessorDomainService.cs @@ -29,13 +29,6 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran } public class DummyFileProcessorManager : IFileProcessorManager { - public Task ValidateUploadReferences(Guid estateId, - Guid merchantId, - Guid userId, - CancellationToken cancellationToken) { - return Task.FromResult(Result.Success()); - } - public async Task>> GetAllFileProfiles(CancellationToken cancellationToken) { return Result.Success(); } diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index 8fad531..4bde44b 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -60,10 +60,6 @@ public FileProcessorDomainServiceTests() Logger.Initialise(NullLogger.Instance); this.FileProcessorManager = new IFileProcessorManagerImposter(); - this.FileProcessorManager.ValidateUploadReferences(Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()).ReturnsAsync(Result.Success()); this.FileProcessorManager.GetFileProfileVersion(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); diff --git a/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs b/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs index 0382f16..6f3dc38 100644 --- a/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs +++ b/FileProcessor.BusinessLogic/Managers/FileProcessorManager.cs @@ -96,29 +96,6 @@ private async Task GetContext(Guid estateId) return resolvedContext.Context; } - public async Task ValidateUploadReferences(Guid estateId, - Guid merchantId, - Guid userId, - CancellationToken cancellationToken) - { - if (estateId == Guid.Empty || merchantId == Guid.Empty || userId == Guid.Empty) - return Result.Invalid("Estate, merchant, and user identifiers are required for file upload"); - - EstateManagementContext context = await this.GetContext(estateId); - Merchant merchant = await context.Merchants - .SingleOrDefaultAsync(m => m.MerchantId == merchantId, cancellationToken); - if (merchant == null || merchant.EstateId != estateId) - return Result.NotFound($"Merchant [{merchantId}] was not found for estate [{estateId}]"); - - EstateSecurityUser user = await context.EstateSecurityUsers - .SingleOrDefaultAsync(u => u.SecurityUserId == userId, cancellationToken); - if (user == null) - return Result.NotFound($"User [{userId}] was not found for estate [{estateId}]"); - - return Result.Success(); - } - - public async Task> GetFileImportLogs(Guid estateId, DateOnly startDate, DateOnly endDate, diff --git a/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs b/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs index 2fb52eb..aaab524 100644 --- a/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs +++ b/FileProcessor.BusinessLogic/Managers/IFileProcessorManager.cs @@ -34,8 +34,6 @@ Task> GetFileProfileVersion(Guid fileProfileId, Task> GetFile(Guid fileId, Guid estateId, CancellationToken cancellationToken); - Task ValidateUploadReferences(Guid estateId, Guid merchantId, Guid userId, CancellationToken cancellationToken); - #endregion } } diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 5da7fa9..19de7b4 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -228,13 +228,6 @@ public async Task UploadFile(FileCommands.UploadFileCommand command, return duplicateResult; } - Result referencesResult = await this.FileProcessorManager.ValidateUploadReferences(command.EstateId, - command.MerchantId, - command.UserId, - cancellationToken); - if (referencesResult.IsFailed) - return ResultHelpers.CreateFailure(referencesResult); - Result getFileProfileResult = await this.FileProcessorManager.GetFileProfile(command.FileProfileId, cancellationToken); if (getFileProfileResult.IsFailed) return ResultHelpers.CreateFailure(getFileProfileResult);