shoc-backend/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs
Arthur Bassi 0f2e0dacc7 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.
2026-08-26 14:38:34 -03:00

449 lines
15 KiB
C#

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;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx) =>
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")
{
var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now };
ctx.Locations.Add(loc);
ctx.SaveChanges();
return loc;
}
private static ClaimsPrincipal OrgWideAdmin()
{
var claims = new List<Claim>
{
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<Claim>
{
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<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, "Dispatcher")
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
[Fact]
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
{
using var ctx = NewContext();
SeedAccount(ctx, 9);
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
Address = "1 Depot Rd",
City = "Austin",
State = "TX",
ZipCode = "73301",
Phone = "555-1000",
ContactEmail = "wh@example.com",
Status = "Active",
AccountId = 9
}, OrgWideAdmin(), CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
entity.AccountId.Should().Be(9);
entity.Title.Should().Be("Main WH");
entity.Address1.Should().Be("1 Depot Rd");
entity.City.Should().Be("Austin");
entity.State.Should().Be("TX");
entity.Zip.Should().Be("73301");
entity.PhoneNumber.Should().Be("555-1000");
entity.Email.Should().Be("wh@example.com");
entity.Status.Should().Be("Active");
}
[Fact]
public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape()
{
using var ctx = NewContext();
SeedLocation(ctx, "Alpha Site", "Austin");
SeedLocation(ctx, "Beta Site", "Dallas");
SeedLocation(ctx, "Gamma Yard", "Austin");
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
page.Items.Should().HaveCount(2);
page.TotalCount.Should().Be(2);
page.Items.All(l => l.City == "Austin").Should().BeTrue();
page.Items.Select(l => l.Name).Should().NotBeNull();
}
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Depot", "Houston");
var service = NewService(ctx);
var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None);
var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None);
missing.Should().BeNull();
found.Should().NotBeNull();
found!.Name.Should().Be("Depot");
found.City.Should().Be("Houston");
}
[Fact]
public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Old", "Round Rock");
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, OrgWideAdmin(), CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("New");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, OrgWideAdmin(), CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Old", "Round Rock");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
City = "Plano"
}, OrgWideAdmin(), CancellationToken.None);
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<UnauthorizedAccessException>();
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<UnauthorizedAccessException>();
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<UnauthorizedAccessException>();
ctx.Locations.Single().Name.Should().Be("Owned");
}
[Fact]
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();
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "Owned",
AccountId = 99
}, OrgWideAdmin(), CancellationToken.None);
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 },
OrgWideAdmin(),
CancellationToken.None);
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
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 },
OrgWideAdmin(),
CancellationToken.None);
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
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<FluentValidation.ValidationException>();
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<UnauthorizedAccessException>();
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<UnauthorizedAccessException>();
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<UnauthorizedAccessException>();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task CreateLocationFromRequestAsync_ForwardsCancellationToAccountLookup()
{
using var ctx = NewContext();
var accounts = new Mock<IAccountDataService>();
CancellationToken seen = default;
accounts
.Setup(a => a.ExistsActiveAsync(9, It.IsAny<CancellationToken>()))
.Callback<int, CancellationToken>((_, 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()
{
using var ctx = NewContext();
var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO
{
LocationName = "Site",
AccountId = 9
}, "42");
created.AccountId.Should().BeNull();
ctx.Locations.Single().AccountId.Should().BeNull();
}
[Fact]
public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Owned", "Austin");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO
{
LocationName = "Owned",
AccountId = 99
}, "42");
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
removed.Should().BeTrue();
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
}