From 2be36d4eacffcd1298eb94384ea02dfe7285b755 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:03:12 -0300 Subject: [PATCH 1/4] feat(locations): persist accountId on create and update Allow location CRUD to stamp Locations.AccountId after account existence checks so board create can resolve tenant scope. --- .../LocationControllerSitesTests.cs | 1 + .../LocationControllerTests.cs | 19 +++++++ .../LocationServiceTests.cs | 49 +++++++++++++++++-- .../Controllers/LocationController.cs | 6 ++- .../Implementation/LocationService.cs | 29 ++++++++++- 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs index ebbd3d6..dc5fded 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -29,6 +29,7 @@ public class LocationControllerSitesTests var dataService = new LocationDataService(ctx); var service = new LocationService( dataService, + new AccountDataService(ctx), Mock.Of(), Mock.Of()); diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index d6dc573..89fed73 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -90,6 +90,25 @@ public class LocationControllerTests service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task AddLocation_MapsAccountIdFromBodyString() + { + var service = new Mock(); + LocationCreateRequestDTO? captured = null; + service + .Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny())) + .Callback((dto, _) => captured = dto) + .Returns(Task.CompletedTask); + + var result = await NewController(service).AddLocation( + new Location_DTO { Name = "X", AccountId = "1" }, + CancellationToken.None); + + result.Should().BeOfType(); + captured.Should().NotBeNull(); + captured!.AccountId.Should().Be(1); + } + [Fact] public async Task EditLocation_WhenMissing_ReturnsNotFound() { diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 844b28f..8c0a273 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -20,7 +20,17 @@ public class LocationServiceTests } private static LocationService NewService(ApplicationDbContext ctx) => - new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation()); + new( + new LocationDataService(ctx), + new AccountDataService(ctx), + new CreateLocationValidation(), + new UpdateLocationValidation()); + + private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer") + { + ctx.Accounts.Add(new Accounts { Id = id, Name = name, IsDeleted = false }); + ctx.SaveChanges(); + } private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active") { @@ -34,6 +44,7 @@ public class LocationServiceTests public async Task CreateLocationFromRequestAsync_PersistsMappedFields() { using var ctx = NewContext(); + SeedAccount(ctx, 9); var service = NewService(ctx); await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO @@ -52,7 +63,7 @@ public class LocationServiceTests var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); - entity.AccountId.Should().BeNull(); + entity.AccountId.Should().Be(9); entity.Title.Should().Be("Main WH"); entity.Address1.Should().Be("1 Depot Rd"); entity.City.Should().Be("Austin"); @@ -137,9 +148,11 @@ public class LocationServiceTests } [Fact] - public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel() + public async Task UpdateLocationFromRequestAsync_AppliesClientAccountIdWhenProvided() { using var ctx = NewContext(); + SeedAccount(ctx, 4); + SeedAccount(ctx, 99); var existing = SeedLocation(ctx, "Owned", "Austin"); existing.AccountId = 4; await ctx.SaveChangesAsync(); @@ -150,7 +163,35 @@ public class LocationServiceTests AccountId = 99 }, CancellationToken.None); - ctx.Locations.Single().AccountId.Should().Be(4); + ctx.Locations.Single().AccountId.Should().Be(99); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_UnknownAccount_ThrowsValidationException() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Owned", "Austin"); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Owned", AccountId = 404 }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Single().AccountId.Should().BeNull(); + } + + [Fact] + public async Task CreateLocationFromRequestAsync_UnknownAccount_ThrowsValidationException() + { + using var ctx = NewContext(); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync( + new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().BeEmpty(); } [Fact] diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 977bdda..dd91983 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -177,7 +177,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } @@ -193,7 +194,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 6be9d0f..a8e22cd 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using FluentValidation; +using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -14,15 +15,18 @@ namespace SeaHaven.Services.Implementation public class LocationService : ILocationService { private readonly ILocationDataService _locationDataService; + private readonly IAccountDataService _accountDataService; private readonly ICreateLocationValidation _createValidator; private readonly IUpdateLocationValidation _updateValidator; public LocationService( ILocationDataService locationDataService, + IAccountDataService accountDataService, ICreateLocationValidation createValidator, IUpdateLocationValidation updateValidator) { _locationDataService = locationDataService; + _accountDataService = accountDataService; _createValidator = createValidator; _updateValidator = updateValidator; } @@ -177,6 +181,8 @@ namespace SeaHaven.Services.Implementation public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) { + await EnsureAccountExistsAsync(request.AccountId); + var location = new Locations { Name = request.Name, @@ -187,7 +193,8 @@ namespace SeaHaven.Services.Implementation Zip = request.ZipCode, PhoneNumber = request.Phone, Email = request.ContactEmail, - Status = request.Status + Status = request.Status, + AccountId = request.AccountId }; await _locationDataService.AddAsync(location, cancellationToken); @@ -209,6 +216,12 @@ namespace SeaHaven.Services.Implementation location.Email = request.ContactEmail; location.Status = request.Status; + if (request.AccountId is int accountId) + { + await EnsureAccountExistsAsync(accountId); + location.AccountId = accountId; + } + await _locationDataService.UpdateAsync(location, cancellationToken); } @@ -217,6 +230,20 @@ namespace SeaHaven.Services.Implementation return _locationDataService.DeleteByIdAsync(id, cancellationToken); } + private async Task EnsureAccountExistsAsync(int? accountId) + { + if (accountId is not int id) + return; + + if (!await _accountDataService.ExistsAsync(id)) + { + throw new ValidationException(new[] + { + new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.") + }); + } + } + // Private helper methods private LocationDTO MapToDTO(Locations location) { From 2718fdd294ee91eb9a0cbc20bf6a3b28ec5e3ad1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:16:59 -0300 Subject: [PATCH 2/4] fix(locations): gate account assignment by scope and active accounts Reject soft-deleted accounts and stop account-scoped callers from assigning or stealing locations across tenants. --- .../LocationControllerTests.cs | 9 +- .../LocationServiceTests.cs | 113 +++++++++++++++++- .../Controllers/LocationController.cs | 13 +- .../Implementation/AccountDataService.cs | 9 ++ .../Interfaces/IAccountDataService.cs | 1 + .../Implementation/LocationService.cs | 45 +++++-- .../Interfaces/ILocationService.cs | 5 +- 7 files changed, 175 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs index 89fed73..29931d5 100644 --- a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; @@ -87,7 +88,7 @@ public class LocationControllerTests var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("200"); response.Message.Should().Be("Location Created Successfully"); - service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny()), Times.Once); + service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -96,8 +97,8 @@ public class LocationControllerTests var service = new Mock(); LocationCreateRequestDTO? captured = null; service - .Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny())) - .Callback((dto, _) => captured = dto) + .Setup(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((dto, _, _) => captured = dto) .Returns(Task.CompletedTask); var result = await NewController(service).AddLocation( @@ -113,7 +114,7 @@ public class LocationControllerTests public async Task EditLocation_WhenMissing_ReturnsNotFound() { var service = new Mock(); - service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny(), It.IsAny())) + service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new KeyNotFoundException()); var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None); diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 8c0a273..5f9c2a7 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -1,8 +1,10 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Validation; using Xunit; @@ -40,6 +42,38 @@ public class LocationServiceTests return loc; } + private static ClaimsPrincipal OrgWideAdmin() + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "admin-1"), + new(ClaimTypes.Role, "Admin"), + new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Dispatcher") + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "actor-1"), + new(ClaimTypes.Role, role), + new(SeaHavenClaimTypes.AccountId, accountId.ToString()) + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal MissingScope() + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, "actor-1"), + new(ClaimTypes.Role, "Dispatcher") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + [Fact] public async Task CreateLocationFromRequestAsync_PersistsMappedFields() { @@ -59,7 +93,7 @@ public class LocationServiceTests ContactEmail = "wh@example.com", Status = "Active", AccountId = 9 - }, CancellationToken.None); + }, OrgWideAdmin(), CancellationToken.None); var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); @@ -118,7 +152,7 @@ public class LocationServiceTests Address = "9 New St", City = "Plano", Status = "Inactive" - }, CancellationToken.None); + }, OrgWideAdmin(), CancellationToken.None); var row = ctx.Locations.Single(); row.Name.Should().Be("New"); @@ -126,7 +160,7 @@ public class LocationServiceTests row.City.Should().Be("Plano"); row.Status.Should().Be("Inactive"); - var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None); + var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); } @@ -142,7 +176,7 @@ public class LocationServiceTests { Name = "New", City = "Plano" - }, CancellationToken.None); + }, OrgWideAdmin(), CancellationToken.None); ctx.Locations.Single().AccountId.Should().Be(4); } @@ -161,7 +195,7 @@ public class LocationServiceTests { Name = "Owned", AccountId = 99 - }, CancellationToken.None); + }, OrgWideAdmin(), CancellationToken.None); ctx.Locations.Single().AccountId.Should().Be(99); } @@ -175,6 +209,7 @@ public class LocationServiceTests var act = () => NewService(ctx).UpdateLocationFromRequestAsync( existing.Id, new LocationUpdateRequestDTO { Name = "Owned", AccountId = 404 }, + OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); @@ -188,12 +223,80 @@ public class LocationServiceTests var act = () => NewService(ctx).CreateLocationFromRequestAsync( new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, + OrgWideAdmin(), CancellationToken.None); await act.Should().ThrowAsync(); ctx.Locations.Should().BeEmpty(); } + [Fact] + public async Task CreateLocationFromRequestAsync_SoftDeletedAccount_ThrowsValidationException() + { + using var ctx = NewContext(); + ctx.Accounts.Add(new Accounts { Id = 9, Name = "Gone", IsDeleted = true }); + ctx.SaveChanges(); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync( + new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }, + OrgWideAdmin(), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().BeEmpty(); + } + + [Fact] + public async Task CreateLocationFromRequestAsync_AccountScopedCaller_CannotAssignOtherAccount() + { + using var ctx = NewContext(); + SeedAccount(ctx, 4); + SeedAccount(ctx, 99); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync( + new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }, + AccountUser(4), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().BeEmpty(); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotStealOtherAccountLocation() + { + using var ctx = NewContext(); + SeedAccount(ctx, 4); + SeedAccount(ctx, 99); + var existing = SeedLocation(ctx, "Owned", "Austin"); + existing.AccountId = 4; + await ctx.SaveChangesAsync(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Owned", AccountId = 99 }, + AccountUser(99), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Single().AccountId.Should().Be(4); + } + + [Fact] + public async Task CreateLocationFromRequestAsync_MissingScope_CannotAssignAccount() + { + using var ctx = NewContext(); + SeedAccount(ctx, 9); + + var act = () => NewService(ctx).CreateLocationFromRequestAsync( + new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }, + MissingScope(), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Should().BeEmpty(); + } + [Fact] public async Task CreateLocationAsync_IgnoresClientAccountId() { diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index dd91983..6d17f9f 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -3,6 +3,7 @@ using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; @@ -101,7 +102,7 @@ namespace Api.SeaHavenIndustries.Controllers { try { - await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken); + await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } catch (ValidationException vex) @@ -109,6 +110,10 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to assign this account." }); + } catch (Exception ex) { return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -120,7 +125,7 @@ namespace Api.SeaHavenIndustries.Controllers { try { - await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken); + await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -128,6 +133,10 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (UnauthorizedAccessException) + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to assign this account." }); + } catch (KeyNotFoundException) { return NotFound(new Response { Status = "Error", Message = "Location not found" }); diff --git a/SeaHaven.DataServices/Implementation/AccountDataService.cs b/SeaHaven.DataServices/Implementation/AccountDataService.cs index 6e46330..5b00af2 100644 --- a/SeaHaven.DataServices/Implementation/AccountDataService.cs +++ b/SeaHaven.DataServices/Implementation/AccountDataService.cs @@ -92,6 +92,15 @@ namespace SeaHaven.DataServices.Implementation return await _context.Accounts.AnyAsync(a => a.Id == id); } + public async Task ExistsActiveAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Accounts + .AsNoTracking() + .AnyAsync( + a => a.Id == id && (a.IsDeleted == false || a.IsDeleted == null), + cancellationToken); + } + public async Task CountAsync() { return await _context.Accounts.CountAsync(); diff --git a/SeaHaven.DataServices/Interfaces/IAccountDataService.cs b/SeaHaven.DataServices/Interfaces/IAccountDataService.cs index e2cb1d2..ad78c55 100644 --- a/SeaHaven.DataServices/Interfaces/IAccountDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IAccountDataService.cs @@ -12,6 +12,7 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Accounts account); Task DeleteAsync(int id); Task ExistsAsync(int id); + Task ExistsActiveAsync(int id, CancellationToken cancellationToken = default); Task CountAsync(); /// diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index a8e22cd..8dd1ad5 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -1,8 +1,10 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentValidation; using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -179,9 +181,12 @@ namespace SeaHaven.Services.Implementation return location == null ? null : MapToDTO(location); } - public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) + public async Task CreateLocationFromRequestAsync( + LocationCreateRequestDTO request, + ClaimsPrincipal user, + CancellationToken cancellationToken) { - await EnsureAccountExistsAsync(request.AccountId); + await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken); var location = new Locations { @@ -200,7 +205,11 @@ namespace SeaHaven.Services.Implementation await _locationDataService.AddAsync(location, cancellationToken); } - public async Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken) + public async Task UpdateLocationFromRequestAsync( + int id, + LocationUpdateRequestDTO request, + ClaimsPrincipal user, + CancellationToken cancellationToken) { var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); if (location == null) @@ -218,7 +227,7 @@ namespace SeaHaven.Services.Implementation if (request.AccountId is int accountId) { - await EnsureAccountExistsAsync(accountId); + await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken); location.AccountId = accountId; } @@ -230,18 +239,40 @@ namespace SeaHaven.Services.Implementation return _locationDataService.DeleteByIdAsync(id, cancellationToken); } - private async Task EnsureAccountExistsAsync(int? accountId) + private async Task EnsureAccountAssignableAsync( + ClaimsPrincipal user, + int? requestedAccountId, + int? existingLocationAccountId, + CancellationToken cancellationToken) { - if (accountId is not int id) + if (requestedAccountId is not int accountId) return; - if (!await _accountDataService.ExistsAsync(id)) + if (!await _accountDataService.ExistsActiveAsync(accountId, cancellationToken)) { throw new ValidationException(new[] { new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.") }); } + + switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) + { + case MediaAccountScope.OrgWide: + return; + + case MediaAccountScope.Account caller: + if (accountId != caller.AccountId + || (existingLocationAccountId is int current && current != caller.AccountId)) + { + throw new UnauthorizedAccessException(); + } + + return; + + default: + throw new UnauthorizedAccessException(); + } } // Private helper methods diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 21d9120..2fa2768 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -18,8 +19,8 @@ namespace SeaHaven.Services.Interfaces Task> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); - Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken); - Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken); + Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); + Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken); Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken); } } From 4e4bb0ca9006afd796d6cea638d72fcefd1266c0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:18:38 -0300 Subject: [PATCH 3/4] 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() From 0f2e0dacc7f9e48b8c1f6e2486a28e741803764f Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 14:38:34 -0300 Subject: [PATCH 4/4] fix(locations): authorize location owner on every board update Reject account-scoped updates of foreign or orphan locations even when accountId is omitted, matching fail-closed tenant scope. --- .../LocationServiceTests.cs | 75 +++++++++++++++++++ .../Implementation/LocationService.cs | 19 +++++ 2 files changed, 94 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index b4bcf34..838526d 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -183,6 +183,81 @@ public class LocationServiceTests ctx.Locations.Single().AccountId.Should().Be(4); } + [Fact] + public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateForeignLocationWhenAccountIdOmitted() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Foreign", "Dallas"); + existing.AccountId = 99; + await ctx.SaveChangesAsync(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Hijacked", City = "Austin" }, + AccountUser(4), + CancellationToken.None); + + await act.Should().ThrowAsync(); + var row = ctx.Locations.Single(); + row.Name.Should().Be("Foreign"); + row.City.Should().Be("Dallas"); + row.AccountId.Should().Be(99); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CanUpdateOwnLocationWhenAccountIdOmitted() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Owned", "Dallas"); + existing.AccountId = 4; + await ctx.SaveChangesAsync(); + + await NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" }, + AccountUser(4), + CancellationToken.None); + + var row = ctx.Locations.Single(); + row.Name.Should().Be("Renamed"); + row.City.Should().Be("Austin"); + row.AccountId.Should().Be(4); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateOrphanWhenAccountIdOmitted() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Orphan", "Dallas"); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Claimed", City = "Austin" }, + AccountUser(4), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Single().Name.Should().Be("Orphan"); + } + + [Fact] + public async Task UpdateLocationFromRequestAsync_MissingScope_CannotUpdate() + { + using var ctx = NewContext(); + var existing = SeedLocation(ctx, "Owned", "Dallas"); + existing.AccountId = 4; + await ctx.SaveChangesAsync(); + + var act = () => NewService(ctx).UpdateLocationFromRequestAsync( + existing.Id, + new LocationUpdateRequestDTO { Name = "Renamed" }, + MissingScope(), + CancellationToken.None); + + await act.Should().ThrowAsync(); + ctx.Locations.Single().Name.Should().Be("Owned"); + } + [Fact] public async Task UpdateLocationFromRequestAsync_AppliesClientAccountIdWhenProvided() { diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 8dd1ad5..3a9e917 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -215,6 +215,8 @@ namespace SeaHaven.Services.Implementation if (location == null) throw new KeyNotFoundException($"Location with ID {id} not found"); + EnsureLocationInCallerScope(user, location.AccountId); + location.Name = request.Name; location.Title = request.Title; location.Address1 = request.Address; @@ -239,6 +241,23 @@ namespace SeaHaven.Services.Implementation return _locationDataService.DeleteByIdAsync(id, cancellationToken); } + private static void EnsureLocationInCallerScope(ClaimsPrincipal user, int? locationAccountId) + { + switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) + { + case MediaAccountScope.OrgWide: + return; + + case MediaAccountScope.Account caller: + if (locationAccountId != caller.AccountId) + throw new UnauthorizedAccessException(); + return; + + default: + throw new UnauthorizedAccessException(); + } + } + private async Task EnsureAccountAssignableAsync( ClaimsPrincipal user, int? requestedAccountId,