!fix(users): restrict DeleteUser to Admin [SH-221]

Align DeleteUser with AddUser/EditUser: Admin role at controller and
service entry, Forbidden for non-Admin, and regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-11 09:44:52 -03:00
parent d0724a0ac5
commit 446e43b2c6
5 changed files with 71 additions and 17 deletions

View file

@ -183,10 +183,13 @@ public class UserControllerTests
public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) service.Setup(s => s.DeleteUserAsync(
.ReturnsAsync(false); It.IsAny<string>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound });
var controller = NewController(service); var controller = NewController(service, "admin-1", "Admin");
var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None); var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None);
@ -199,14 +202,38 @@ public class UserControllerTests
public async Task DeleteUser_Found_DelegatesAndReturnsOk() public async Task DeleteUser_Found_DelegatesAndReturnsOk()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>())).ReturnsAsync(true); service.Setup(s => s.DeleteUserAsync(
"5",
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = true });
var controller = NewController(service); var controller = NewController(service, "admin-1", "Admin");
var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>(); result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>()), Times.Once); service.Verify(s => s.DeleteUserAsync(
"5",
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteUser_Forbidden_ReturnsForbid()
{
var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync(
It.IsAny<string>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden });
var controller = NewController(service, "tech-1", "User");
var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None);
result.Should().BeOfType<ForbidResult>();
} }
[Fact] [Fact]

View file

@ -61,16 +61,17 @@ public class UserServiceTests
} }
[Fact] [Fact]
public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade() public async Task DeleteUser_NotFound_ReturnsUserNotFoundWithoutCascade()
{ {
var userData = new Mock<IUserDataService>(); var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null); userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out _); var service = NewService(userData, new Mock<IEmailSender>(), out _);
var found = await service.DeleteUserAsync("missing", CancellationToken.None); var outcome = await service.DeleteUserAsync("missing", Principal("Admin"), CancellationToken.None);
found.Should().BeFalse(); outcome.Success.Should().BeFalse();
outcome.Error.Should().Be(UserMutationErrors.UserNotFound);
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
} }
@ -83,12 +84,28 @@ public class UserServiceTests
var service = NewService(userData, new Mock<IEmailSender>(), out _); var service = NewService(userData, new Mock<IEmailSender>(), out _);
var found = await service.DeleteUserAsync("u1", CancellationToken.None); var outcome = await service.DeleteUserAsync("u1", Principal("Admin"), CancellationToken.None);
found.Should().BeTrue(); outcome.Success.Should().BeTrue();
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once); userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact]
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var outcome = await service.DeleteUserAsync("u1", Principal("User"), CancellationToken.None);
outcome.Success.Should().BeFalse();
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact] [Fact]
public async Task DeleteCurrentUser_SetsIsDeletedFlag() public async Task DeleteCurrentUser_SetsIsDeletedFlag()
{ {

View file

@ -94,14 +94,17 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpDelete] [HttpDelete]
[Route("DeleteUser")] [Route("DeleteUser")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken)
{ {
string id = req.Id ?? ""; string id = req.Id ?? "";
try try
{ {
var found = await _userService.DeleteUserAsync(id, cancellationToken); var outcome = await _userService.DeleteUserAsync(id, User, cancellationToken);
if (!found) if (!outcome.Success)
{ {
if (string.Equals(outcome.Error, UserMutationErrors.Forbidden, StringComparison.Ordinal))
return Forbid();
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
} }
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });

View file

@ -161,14 +161,21 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = true }; return new AddUserOutcomeDTO { Success = true };
} }
public async Task<bool> DeleteUserAsync(string id, CancellationToken cancellationToken) public async Task<AddUserOutcomeDTO> DeleteUserAsync(
string id,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{ {
var authFailure = EnsureAdminCaller(caller);
if (authFailure != null)
return authFailure;
var data = await _userDataService.GetForEditAsync(id, cancellationToken); var data = await _userDataService.GetForEditAsync(id, cancellationToken);
if (data == null) if (data == null)
return false; return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken); await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
return true; return new AddUserOutcomeDTO { Success = true };
} }
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken) public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)

View file

@ -8,7 +8,7 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken); Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken);
Task<AddUserOutcomeDTO> AddUserAsync(AddUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> AddUserAsync(AddUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task<AddUserOutcomeDTO> EditUserAsync(EditUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> EditUserAsync(EditUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task<bool> DeleteUserAsync(string id, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> DeleteUserAsync(string id, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken); Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserProfileRowDTO>> GetUserProfileAsync(string userId, CancellationToken cancellationToken); Task<IReadOnlyList<UserProfileRowDTO>> GetUserProfileAsync(string userId, CancellationToken cancellationToken);
} }