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); } }