mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #88 from Sea-Haven-Industries/feat/sh-221-persist-location-accountid
feat(locations): persist accountId on create and update
This commit is contained in:
commit
6c826b4cc6
11 changed files with 455 additions and 23 deletions
|
|
@ -29,6 +29,7 @@ public class LocationControllerSitesTests
|
||||||
var dataService = new LocationDataService(ctx);
|
var dataService = new LocationDataService(ctx);
|
||||||
var service = new LocationService(
|
var service = new LocationService(
|
||||||
dataService,
|
dataService,
|
||||||
|
new AccountDataService(ctx),
|
||||||
Mock.Of<ICreateLocationValidation>(),
|
Mock.Of<ICreateLocationValidation>(),
|
||||||
Mock.Of<IUpdateLocationValidation>());
|
Mock.Of<IUpdateLocationValidation>());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
|
@ -87,14 +88,73 @@ public class LocationControllerTests
|
||||||
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
response.Status.Should().Be("200");
|
response.Status.Should().Be("200");
|
||||||
response.Message.Should().Be("Location Created Successfully");
|
response.Message.Should().Be("Location Created Successfully");
|
||||||
service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<CancellationToken>()), Times.Once);
|
service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLocation_MapsAccountIdFromBodyString()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
LocationCreateRequestDTO? captured = null;
|
||||||
|
service
|
||||||
|
.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<LocationCreateRequestDTO, ClaimsPrincipal, CancellationToken>((dto, _, _) => captured = dto)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await NewController(service).AddLocation(
|
||||||
|
new Location_DTO { Name = "X", AccountId = "1" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.AccountId.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLocation_InvalidAccountIdString_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
|
||||||
|
var result = await NewController(service).AddLocation(
|
||||||
|
new Location_DTO { Name = "X", AccountId = "abc" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Contain("accountId must be a valid integer.");
|
||||||
|
service.Verify(
|
||||||
|
s => s.CreateLocationFromRequestAsync(
|
||||||
|
It.IsAny<LocationCreateRequestDTO>(),
|
||||||
|
It.IsAny<ClaimsPrincipal>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditLocation_InvalidAccountIdString_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
|
||||||
|
var result = await NewController(service).EditLocation(
|
||||||
|
1,
|
||||||
|
new EditLocation_DTO { Name = "X", AccountId = "abc" },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Contain("accountId must be a valid integer.");
|
||||||
|
service.Verify(
|
||||||
|
s => s.UpdateLocationFromRequestAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<LocationUpdateRequestDTO>(),
|
||||||
|
It.IsAny<ClaimsPrincipal>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EditLocation_WhenMissing_ReturnsNotFound()
|
public async Task EditLocation_WhenMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var service = new Mock<ILocationService>();
|
var service = new Mock<ILocationService>();
|
||||||
service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<CancellationToken>()))
|
service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new KeyNotFoundException());
|
.ThrowsAsync(new KeyNotFoundException());
|
||||||
|
|
||||||
var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None);
|
var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Validation;
|
using SeaHaven.Services.Validation;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -20,7 +24,17 @@ public class LocationServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocationService NewService(ApplicationDbContext ctx) =>
|
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")
|
private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active")
|
||||||
{
|
{
|
||||||
|
|
@ -30,10 +44,43 @@ public class LocationServiceTests
|
||||||
return loc;
|
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]
|
[Fact]
|
||||||
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
|
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
using var ctx = NewContext();
|
||||||
|
SeedAccount(ctx, 9);
|
||||||
var service = NewService(ctx);
|
var service = NewService(ctx);
|
||||||
|
|
||||||
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||||
|
|
@ -48,11 +95,11 @@ public class LocationServiceTests
|
||||||
ContactEmail = "wh@example.com",
|
ContactEmail = "wh@example.com",
|
||||||
Status = "Active",
|
Status = "Active",
|
||||||
AccountId = 9
|
AccountId = 9
|
||||||
}, CancellationToken.None);
|
}, OrgWideAdmin(), CancellationToken.None);
|
||||||
|
|
||||||
var entity = ctx.Locations.Single();
|
var entity = ctx.Locations.Single();
|
||||||
entity.Name.Should().Be("Warehouse");
|
entity.Name.Should().Be("Warehouse");
|
||||||
entity.AccountId.Should().BeNull();
|
entity.AccountId.Should().Be(9);
|
||||||
entity.Title.Should().Be("Main WH");
|
entity.Title.Should().Be("Main WH");
|
||||||
entity.Address1.Should().Be("1 Depot Rd");
|
entity.Address1.Should().Be("1 Depot Rd");
|
||||||
entity.City.Should().Be("Austin");
|
entity.City.Should().Be("Austin");
|
||||||
|
|
@ -107,7 +154,7 @@ public class LocationServiceTests
|
||||||
Address = "9 New St",
|
Address = "9 New St",
|
||||||
City = "Plano",
|
City = "Plano",
|
||||||
Status = "Inactive"
|
Status = "Inactive"
|
||||||
}, CancellationToken.None);
|
}, OrgWideAdmin(), CancellationToken.None);
|
||||||
|
|
||||||
var row = ctx.Locations.Single();
|
var row = ctx.Locations.Single();
|
||||||
row.Name.Should().Be("New");
|
row.Name.Should().Be("New");
|
||||||
|
|
@ -115,7 +162,7 @@ public class LocationServiceTests
|
||||||
row.City.Should().Be("Plano");
|
row.City.Should().Be("Plano");
|
||||||
row.Status.Should().Be("Inactive");
|
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<KeyNotFoundException>();
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,15 +178,92 @@ public class LocationServiceTests
|
||||||
{
|
{
|
||||||
Name = "New",
|
Name = "New",
|
||||||
City = "Plano"
|
City = "Plano"
|
||||||
}, CancellationToken.None);
|
}, OrgWideAdmin(), CancellationToken.None);
|
||||||
|
|
||||||
ctx.Locations.Single().AccountId.Should().Be(4);
|
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel()
|
public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateForeignLocationWhenAccountIdOmitted()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
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");
|
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||||
existing.AccountId = 4;
|
existing.AccountId = 4;
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
|
@ -148,11 +272,136 @@ public class LocationServiceTests
|
||||||
{
|
{
|
||||||
Name = "Owned",
|
Name = "Owned",
|
||||||
AccountId = 99
|
AccountId = 99
|
||||||
}, CancellationToken.None);
|
}, 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);
|
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]
|
[Fact]
|
||||||
public async Task CreateLocationAsync_IgnoresClientAccountId()
|
public async Task CreateLocationAsync_IgnoresClientAccountId()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Api.SeaHavenIndustries.Helper;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -101,7 +102,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken);
|
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
|
||||||
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -109,6 +110,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
|
|
@ -120,7 +125,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
try
|
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" });
|
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -128,6 +133,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
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)
|
catch (KeyNotFoundException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||||
|
|
@ -177,7 +186,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
ZipCode = model.ZipCode,
|
ZipCode = model.ZipCode,
|
||||||
Phone = model.Phone,
|
Phone = model.Phone,
|
||||||
ContactEmail = model.ContactEmail,
|
ContactEmail = model.ContactEmail,
|
||||||
Status = model.Status
|
Status = model.Status,
|
||||||
|
AccountId = model.GetAccountId()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +203,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
ZipCode = model.ZipCode,
|
ZipCode = model.ZipCode,
|
||||||
Phone = model.Phone,
|
Phone = model.Phone,
|
||||||
ContactEmail = model.ContactEmail,
|
ContactEmail = model.ContactEmail,
|
||||||
Status = model.Status
|
Status = model.Status,
|
||||||
|
AccountId = model.GetAccountId()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? AccountId { get; set; }
|
public string? AccountId { get; set; }
|
||||||
|
|
||||||
public int? GetAccountId() =>
|
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
|
||||||
int.TryParse(AccountId, out var id) ? id : null;
|
|
||||||
|
|
||||||
// ✅ Map API DTO to Service DTO
|
// ✅ Map API DTO to Service DTO
|
||||||
public UpdateLocationDTO ToServiceUpdateDTO()
|
public UpdateLocationDTO ToServiceUpdateDTO()
|
||||||
|
|
|
||||||
25
Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs
Normal file
25
Api.SeaHavenIndustries/DTOs/LocationAccountIdMapping.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? AccountId { get; set; }
|
public string? AccountId { get; set; }
|
||||||
|
|
||||||
public int? GetAccountId() =>
|
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
|
||||||
int.TryParse(AccountId, out var id) ? id : null;
|
|
||||||
|
|
||||||
// ✅ Map API DTO to Service DTO
|
// ✅ Map API DTO to Service DTO
|
||||||
public CreateLocationDTO ToServiceCreateDTO()
|
public CreateLocationDTO ToServiceCreateDTO()
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await _context.Accounts.AnyAsync(a => a.Id == id);
|
return await _context.Accounts.AnyAsync(a => a.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<int> CountAsync()
|
public async Task<int> CountAsync()
|
||||||
{
|
{
|
||||||
return await _context.Accounts.CountAsync();
|
return await _context.Accounts.CountAsync();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task UpdateAsync(Accounts account);
|
Task UpdateAsync(Accounts account);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
|
Task<bool> ExistsActiveAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using FluentValidation.Results;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.Validation;
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
|
@ -14,15 +17,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
public class LocationService : ILocationService
|
public class LocationService : ILocationService
|
||||||
{
|
{
|
||||||
private readonly ILocationDataService _locationDataService;
|
private readonly ILocationDataService _locationDataService;
|
||||||
|
private readonly IAccountDataService _accountDataService;
|
||||||
private readonly ICreateLocationValidation _createValidator;
|
private readonly ICreateLocationValidation _createValidator;
|
||||||
private readonly IUpdateLocationValidation _updateValidator;
|
private readonly IUpdateLocationValidation _updateValidator;
|
||||||
|
|
||||||
public LocationService(
|
public LocationService(
|
||||||
ILocationDataService locationDataService,
|
ILocationDataService locationDataService,
|
||||||
|
IAccountDataService accountDataService,
|
||||||
ICreateLocationValidation createValidator,
|
ICreateLocationValidation createValidator,
|
||||||
IUpdateLocationValidation updateValidator)
|
IUpdateLocationValidation updateValidator)
|
||||||
{
|
{
|
||||||
_locationDataService = locationDataService;
|
_locationDataService = locationDataService;
|
||||||
|
_accountDataService = accountDataService;
|
||||||
_createValidator = createValidator;
|
_createValidator = createValidator;
|
||||||
_updateValidator = updateValidator;
|
_updateValidator = updateValidator;
|
||||||
}
|
}
|
||||||
|
|
@ -175,8 +181,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
return location == null ? null : MapToDTO(location);
|
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 EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken);
|
||||||
|
|
||||||
var location = new Locations
|
var location = new Locations
|
||||||
{
|
{
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
|
|
@ -187,18 +198,25 @@ namespace SeaHaven.Services.Implementation
|
||||||
Zip = request.ZipCode,
|
Zip = request.ZipCode,
|
||||||
PhoneNumber = request.Phone,
|
PhoneNumber = request.Phone,
|
||||||
Email = request.ContactEmail,
|
Email = request.ContactEmail,
|
||||||
Status = request.Status
|
Status = request.Status,
|
||||||
|
AccountId = request.AccountId
|
||||||
};
|
};
|
||||||
|
|
||||||
await _locationDataService.AddAsync(location, cancellationToken);
|
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);
|
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
|
||||||
if (location == null)
|
if (location == null)
|
||||||
throw new KeyNotFoundException($"Location with ID {id} not found");
|
throw new KeyNotFoundException($"Location with ID {id} not found");
|
||||||
|
|
||||||
|
EnsureLocationInCallerScope(user, location.AccountId);
|
||||||
|
|
||||||
location.Name = request.Name;
|
location.Name = request.Name;
|
||||||
location.Title = request.Title;
|
location.Title = request.Title;
|
||||||
location.Address1 = request.Address;
|
location.Address1 = request.Address;
|
||||||
|
|
@ -209,6 +227,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
location.Email = request.ContactEmail;
|
location.Email = request.ContactEmail;
|
||||||
location.Status = request.Status;
|
location.Status = request.Status;
|
||||||
|
|
||||||
|
if (request.AccountId is int accountId)
|
||||||
|
{
|
||||||
|
await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken);
|
||||||
|
location.AccountId = accountId;
|
||||||
|
}
|
||||||
|
|
||||||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +241,59 @@ namespace SeaHaven.Services.Implementation
|
||||||
return _locationDataService.DeleteByIdAsync(id, cancellationToken);
|
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,
|
||||||
|
int? existingLocationAccountId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (requestedAccountId is not int accountId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
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
|
// Private helper methods
|
||||||
private LocationDTO MapToDTO(Locations location)
|
private LocationDTO MapToDTO(Locations location)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Interfaces
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
|
@ -18,8 +19,8 @@ namespace SeaHaven.Services.Interfaces
|
||||||
|
|
||||||
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||||
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
||||||
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken);
|
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken);
|
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||||
Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken);
|
Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue