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,