From 4e4bb0ca9006afd796d6cea638d72fcefd1266c0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:18:38 -0300 Subject: [PATCH] fix(locations): reject unparseable accountId and forward cancellation Blank accountId stays optional; nonblank parse failures return 400. Account lookup uses ExistsActiveAsync with the request token. --- .../LocationControllerTests.cs | 40 +++++++++++++++++++ .../LocationServiceTests.cs | 30 ++++++++++++++ .../DTOs/EditLocation_DTO.cs | 3 +- .../DTOs/LocationAccountIdMapping.cs | 25 ++++++++++++ Api.SeaHavenIndustries/DTOs/Location_DTO.cs | 3 +- 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 29931d5..f8f3487 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -110,6 +110,46 @@ public class LocationControllerTests captured!.AccountId.Should().Be(1); } + [Fact] + public async Task AddLocation_InvalidAccountIdString_ReturnsValidationError() + { + var service = new Mock(); + + var result = await NewController(service).AddLocation( + new Location_DTO { Name = "X", AccountId = "abc" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Contain("accountId must be a valid integer."); + service.Verify( + s => s.CreateLocationFromRequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task EditLocation_InvalidAccountIdString_ReturnsValidationError() + { + var service = new Mock(); + + var result = await NewController(service).EditLocation( + 1, + new EditLocation_DTO { Name = "X", AccountId = "abc" }, + CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Contain("accountId must be a valid integer."); + service.Verify( + s => s.UpdateLocationFromRequestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Fact] public async Task EditLocation_WhenMissing_ReturnsNotFound() { diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 5f9c2a7..b4bcf34 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -2,7 +2,9 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Moq; using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; @@ -297,6 +299,34 @@ public class LocationServiceTests ctx.Locations.Should().BeEmpty(); } + [Fact] + public async Task CreateLocationFromRequestAsync_ForwardsCancellationToAccountLookup() + { + using var ctx = NewContext(); + var accounts = new Mock(); + CancellationToken seen = default; + accounts + .Setup(a => a.ExistsActiveAsync(9, It.IsAny())) + .Callback((_, token) => seen = token) + .ReturnsAsync(true); + + var service = new LocationService( + new LocationDataService(ctx), + accounts.Object, + new CreateLocationValidation(), + new UpdateLocationValidation()); + + using var cts = new CancellationTokenSource(); + + await service.CreateLocationFromRequestAsync( + new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + OrgWideAdmin(), + cts.Token); + + seen.Should().Be(cts.Token); + accounts.Verify(a => a.ExistsActiveAsync(9, cts.Token), Times.Once); + } + [Fact] public async Task CreateLocationAsync_IgnoresClientAccountId() { diff --git a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs index 43ad77c..d7d3d63 100644 --- a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs @@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? Status { get; set; } public string? AccountId { get; set; } - public int? GetAccountId() => - int.TryParse(AccountId, out var id) ? id : null; + public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); // ✅ Map API DTO to Service DTO public UpdateLocationDTO ToServiceUpdateDTO() diff --git a/Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs b/Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs new file mode 100644 index 0000000..a36809f --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using FluentValidation; +using FluentValidation.Results; + +namespace Api.SeaHavenIndustries.DTOs +{ + internal static class LocationAccountIdMapping + { + public static int? ParseOptional(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) + { + throw new ValidationException(new[] + { + new ValidationFailure("AccountId", "accountId must be a valid integer.") + }); + } + + return id; + } + } +} diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index 6e38315..68aa518 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? Status { get; set; } public string? AccountId { get; set; } - public int? GetAccountId() => - int.TryParse(AccountId, out var id) ? id : null; + public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId); // ✅ Map API DTO to Service DTO public CreateLocationDTO ToServiceCreateDTO()