mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
458 lines
20 KiB
C#
458 lines
20 KiB
C#
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Interfaces;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public sealed class TeamMemberServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task Create_DispatcherRequiresAtLeastOneServiceArea()
|
|
{
|
|
var userManager = UserManager();
|
|
var roleManager = RoleManager();
|
|
var service = new TeamMemberService(
|
|
userManager.Object,
|
|
roleManager.Object,
|
|
Mock.Of<IUserServiceAreaDataService>(),
|
|
Mock.Of<ITeamPermissionOverrideDataService>(),
|
|
Mock.Of<IUserDataService>(),
|
|
Mock.Of<ITeamPermissionService>(),
|
|
Mock.Of<ITeamMemberInviteService>());
|
|
|
|
var result = await service.CreateAsync(
|
|
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
result.Success.Should().BeFalse();
|
|
result.Error.Should().Be("At least one service area is required for a Dispatcher.");
|
|
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_RejectsUnknownColorAndPermission()
|
|
{
|
|
var service = NewService(out var userManager, out _, out _, out _, out _, out _);
|
|
|
|
var badColor = await service.CreateAsync(
|
|
ValidRequest() with { Color = "#000000" },
|
|
Admin(),
|
|
CancellationToken.None);
|
|
var badPermission = await service.CreateAsync(
|
|
ValidRequest() with
|
|
{
|
|
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
|
{
|
|
["not-a-permission"] = UserPermissionState.Allow
|
|
}
|
|
},
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
badColor.Error.Should().Be("Color must be selected from the accessible palette.");
|
|
badPermission.Error.Should().Be("Unknown permission key: not-a-permission.");
|
|
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides()
|
|
{
|
|
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
|
|
ApplicationUser? created = null;
|
|
userManager
|
|
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
|
.Callback<ApplicationUser>(user =>
|
|
{
|
|
user.Id = "new-user";
|
|
created = user;
|
|
})
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
userManager
|
|
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
|
.ReturnsAsync((ApplicationUser?)null);
|
|
userManager
|
|
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
|
|
|
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
|
|
|
result.Success.Should().BeTrue();
|
|
created.Should().NotBeNull();
|
|
created!.PasswordHash.Should().BeNull();
|
|
created.EmailConfirmed.Should().BeFalse();
|
|
created.PendingRegistration.Should().BeTrue();
|
|
created.PhoneNumber.Should().Be("555-0100");
|
|
result.Member!.ServiceAreas.Should().Equal("East", "West");
|
|
areas.Verify(data => data.ReplaceAsync("new-user", It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
|
|
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation()
|
|
{
|
|
var service = NewService(
|
|
out var userManager,
|
|
out _,
|
|
out _,
|
|
out _,
|
|
out var userData,
|
|
out _);
|
|
var owner = new ApplicationUser
|
|
{
|
|
Id = "owner",
|
|
FirstName = "Primary",
|
|
Email = "owner@example.com",
|
|
IsAccountOwner = true
|
|
};
|
|
userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner);
|
|
userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List<string> { "Admin" });
|
|
userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
|
|
|
var result = await service.UpdateAsync(
|
|
owner.Id,
|
|
new UpdateTeamMemberRequestDTO
|
|
{
|
|
Name = "Primary",
|
|
Role = "Dispatcher",
|
|
Color = "#F59E0B",
|
|
Email = owner.Email,
|
|
ServiceAreas = new[] { "East" },
|
|
IsActive = false
|
|
},
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
result.Success.Should().BeFalse();
|
|
result.Error.Should().Be("Forbidden");
|
|
userData.Verify(data => data.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail()
|
|
{
|
|
var service = NewService(
|
|
out var userManager,
|
|
out var roleManager,
|
|
out var areas,
|
|
out var overrides,
|
|
out var userData,
|
|
out var permissions);
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = "dispatcher",
|
|
FirstName = "Taylor",
|
|
Email = "old@example.com",
|
|
Color = "#F59E0B",
|
|
UniqueName = "Active"
|
|
};
|
|
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
|
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Dispatcher" });
|
|
userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
|
|
userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com"))
|
|
.Callback<ApplicationUser, string>((member, email) => member.Email = email)
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com"))
|
|
.Callback<ApplicationUser, string>((member, email) => member.UserName = email)
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
userManager.Setup(manager => manager.UpdateSecurityStampAsync(user))
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
|
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new[] { "Central" });
|
|
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
|
{
|
|
UserId = user.Id,
|
|
RoleName = "Dispatcher",
|
|
Permissions = new[]
|
|
{
|
|
new TeamPermissionValueDTO
|
|
{
|
|
PermissionKey = "createSites",
|
|
OverrideState = UserPermissionState.Deny,
|
|
IsGranted = false
|
|
}
|
|
}
|
|
}));
|
|
|
|
var result = await service.UpdateAsync(
|
|
user.Id,
|
|
new UpdateTeamMemberRequestDTO
|
|
{
|
|
Name = "Taylor Updated",
|
|
Role = "Dispatcher",
|
|
Color = "#0D9488",
|
|
Email = "new@example.com",
|
|
Phone = "555-0101",
|
|
ServiceAreas = new[] { "West" },
|
|
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
|
{
|
|
["createSites"] = UserPermissionState.Deny
|
|
}
|
|
},
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
result.Success.Should().BeTrue();
|
|
result.Member!.Name.Should().Be("Taylor Updated");
|
|
result.Member.Email.Should().Be("new@example.com");
|
|
result.Member.ServiceAreas.Should().Equal("Central");
|
|
result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites");
|
|
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
|
userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once);
|
|
areas.Verify(data => data.ReplaceAsync(user.Id, It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "West" })), It.IsAny<CancellationToken>()), Times.Once);
|
|
overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny<CancellationToken>()), Times.Once);
|
|
overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_SavesUnchangedNameWithoutDuplicatingRoleLikeLastName()
|
|
{
|
|
var service = NewService(
|
|
out var userManager,
|
|
out var roleManager,
|
|
out var areas,
|
|
out _,
|
|
out var userData,
|
|
out var permissions);
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = "admin",
|
|
FirstName = "Legacy",
|
|
LastName = "Manager",
|
|
Email = "legacy@example.com",
|
|
Color = "#F59E0B",
|
|
UniqueName = "Active"
|
|
};
|
|
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
|
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Admin" });
|
|
userManager.Setup(manager => manager.FindByEmailAsync(user.Email!)).ReturnsAsync(user);
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Admin")).ReturnsAsync(true);
|
|
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Array.Empty<string>());
|
|
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
|
{
|
|
UserId = user.Id,
|
|
Permissions = Array.Empty<TeamPermissionValueDTO>()
|
|
}));
|
|
|
|
var result = await service.UpdateAsync(
|
|
user.Id,
|
|
new UpdateTeamMemberRequestDTO
|
|
{
|
|
Name = "Legacy Manager",
|
|
Role = "Admin",
|
|
Color = "#F59E0B",
|
|
Email = user.Email
|
|
},
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
result.Success.Should().BeTrue();
|
|
user.FirstName.Should().Be("Legacy");
|
|
user.LastName.Should().Be("Manager");
|
|
result.Member!.Name.Should().Be("Legacy Manager");
|
|
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_LeavesOmittedOverridesUnchanged()
|
|
{
|
|
var service = NewService(
|
|
out var userManager,
|
|
out var roleManager,
|
|
out var areas,
|
|
out var overrides,
|
|
out var userData,
|
|
out var permissions);
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = "dispatcher",
|
|
FirstName = "Taylor",
|
|
Email = "taylor@example.com",
|
|
Color = "#F59E0B",
|
|
UniqueName = "Active"
|
|
};
|
|
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
|
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Dispatcher" });
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
|
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<string>());
|
|
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
|
{
|
|
UserId = user.Id,
|
|
Permissions = Array.Empty<TeamPermissionValueDTO>()
|
|
}));
|
|
|
|
var result = await service.UpdateAsync(
|
|
user.Id,
|
|
new UpdateTeamMemberRequestDTO
|
|
{
|
|
Name = "Taylor",
|
|
Role = "Dispatcher",
|
|
Color = "#F59E0B",
|
|
Email = user.Email,
|
|
ServiceAreas = new[] { "East" }
|
|
},
|
|
Admin(),
|
|
CancellationToken.None);
|
|
|
|
result.Success.Should().BeTrue();
|
|
overrides.Verify(data => data.ClearOverridesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
overrides.Verify(data => data.SetOverridesAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
|
|
{
|
|
var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _);
|
|
userManager
|
|
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
|
.ReturnsAsync((ApplicationUser?)null);
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
|
userManager
|
|
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
|
.ThrowsAsync(new DbUpdateException("duplicate key", new Exception()));
|
|
|
|
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
|
|
|
result.Success.Should().BeFalse();
|
|
result.Error.Should().Be("Email is already in use.");
|
|
userData.Verify(
|
|
data => data.ExecuteTransactionalAsync(It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
|
|
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation()
|
|
{
|
|
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
|
|
ApplicationUser? created = null;
|
|
userManager
|
|
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
|
.ReturnsAsync((ApplicationUser?)null);
|
|
userManager
|
|
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
|
.Callback<ApplicationUser>(user =>
|
|
{
|
|
user.Id = "mid-fail-user";
|
|
created = user;
|
|
})
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
userManager
|
|
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
|
areas
|
|
.Setup(data => data.ReplaceAsync(
|
|
"mid-fail-user",
|
|
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
overrides
|
|
.Setup(data => data.SetOverridesAsync(
|
|
"mid-fail-user",
|
|
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
|
|
|
|
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
|
|
|
|
failure.Should().BeOfType<InvalidOperationException>()
|
|
.Which.Message.Should().Be("injected mid-sequence failure");
|
|
created.Should().NotBeNull();
|
|
areas.Verify(
|
|
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
|
}
|
|
|
|
private static TeamMemberService NewService(
|
|
out Mock<UserManager<ApplicationUser>> userManager,
|
|
out Mock<RoleManager<IdentityRole>> roleManager,
|
|
out Mock<IUserServiceAreaDataService> areas,
|
|
out Mock<ITeamPermissionOverrideDataService> overrides,
|
|
out Mock<IUserDataService> userData,
|
|
out Mock<ITeamPermissionService> permissions)
|
|
{
|
|
userManager = UserManager();
|
|
roleManager = RoleManager();
|
|
areas = new Mock<IUserServiceAreaDataService>();
|
|
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
|
userData = new Mock<IUserDataService>();
|
|
permissions = new Mock<ITeamPermissionService>();
|
|
userData
|
|
.Setup(data => data.ExecuteTransactionalAsync(
|
|
It.IsAny<Func<CancellationToken, Task>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
|
|
return new TeamMemberService(
|
|
userManager.Object,
|
|
roleManager.Object,
|
|
areas.Object,
|
|
overrides.Object,
|
|
userData.Object,
|
|
permissions.Object,
|
|
Mock.Of<ITeamMemberInviteService>());
|
|
}
|
|
|
|
private static Mock<UserManager<ApplicationUser>> UserManager()
|
|
{
|
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
|
return new Mock<UserManager<ApplicationUser>>(
|
|
store.Object,
|
|
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
|
|
Mock.Of<IPasswordHasher<ApplicationUser>>(),
|
|
Array.Empty<IUserValidator<ApplicationUser>>(),
|
|
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
|
Mock.Of<ILookupNormalizer>(),
|
|
new IdentityErrorDescriber(),
|
|
Mock.Of<IServiceProvider>(),
|
|
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
|
|
}
|
|
|
|
private static Mock<RoleManager<IdentityRole>> RoleManager()
|
|
{
|
|
var store = new Mock<IRoleStore<IdentityRole>>();
|
|
return new Mock<RoleManager<IdentityRole>>(
|
|
store.Object,
|
|
Array.Empty<IRoleValidator<IdentityRole>>(),
|
|
Mock.Of<ILookupNormalizer>(),
|
|
new IdentityErrorDescriber(),
|
|
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
|
|
}
|
|
|
|
private static CreateTeamMemberRequestDTO ValidRequest() => new()
|
|
{
|
|
Name = "Taylor Dispatcher",
|
|
Role = "dispatcher",
|
|
Color = "#F59E0B",
|
|
Email = "taylor@example.com",
|
|
Phone = "555-0100",
|
|
ServiceAreas = new[] { "east", "West" },
|
|
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
|
{
|
|
["deleteSites"] = UserPermissionState.Allow
|
|
}
|
|
};
|
|
|
|
private static ClaimsPrincipal Admin() =>
|
|
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
|
|
}
|