Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 110 additions & 12 deletions Backend.Tests/Controllers/UserControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ namespace Backend.Tests.Controllers
{
internal sealed class UserControllerTests : IDisposable
{
private IProjectRepository _projectRepo = null!;
private IUserRepository _userRepo = null!;
private IUserEditRepository _userEditRepo = null!;
private IUserRoleRepository _userRoleRepo = null!;
private UserController _userController = null!;

public void Dispose()
Expand All @@ -23,9 +26,12 @@ public void Dispose()
[SetUp]
public void Setup()
{
_projectRepo = new ProjectRepositoryMock();
_userRepo = new UserRepositoryMock();
_userController = new UserController(
_userRepo, new CaptchaServiceMock(), new PermissionServiceMock(_userRepo));
_userEditRepo = new UserEditRepositoryMock();
_userRoleRepo = new UserRoleRepositoryMock();
_userController = new UserController(_projectRepo, _userRepo, _userEditRepo, _userRoleRepo,
new CaptchaServiceMock(), new PermissionServiceMock(_userRepo));
}

private static User RandomUser()
Expand Down Expand Up @@ -171,8 +177,7 @@ public void TestCreateUser()
[Test]
public void TestCreateUserBadUsername()
{
var user = RandomUser();
_userRepo.Create(user);
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();

var user2 = RandomUser();
user2.Username = " ";
Expand All @@ -186,8 +191,7 @@ public void TestCreateUserBadUsername()
[Test]
public void TestCreateUserBadEmail()
{
var user = RandomUser();
_userRepo.Create(user);
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();

var user2 = RandomUser();
user2.Email = " ";
Expand Down Expand Up @@ -259,15 +263,109 @@ public void TestDeleteUserNoPermission()
Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public void TestDeleteUserWithRolesAndEdits()
{
// Create a user, project, user role, and user edit
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
var project = _projectRepo.Create(new() { Name = "Test Project" }).Result
?? throw new ProjectCreationException();
var userRole = _userRoleRepo.Create(new() { ProjectId = project.Id, Role = Role.Editor }).Result
?? throw new UserRoleCreationException();
var userEdit = _userEditRepo.Create(new() { ProjectId = project.Id }).Result
?? throw new UserEditCreationException();

// Add role and edit to user
user.ProjectRoles[project.Id] = userRole.Id;
user.WorkedProjects[project.Id] = userEdit.Id;
_ = _userRepo.Update(user.Id, user).Result;

// Verify they exist
Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Not.Null);
Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Not.Null);

// Delete the user
_ = _userController.DeleteUser(user.Id).Result;

// Verify user is deleted
Assert.That(_userRepo.GetAllUsers().Result, Is.Empty);

// Verify user role and edit are deleted
Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Null);
Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Null);
}

[Test]
public void TestDeleteAdminUser()
{
// Create an admin user
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
user.IsAdmin = true;
_ = _userRepo.Update(user.Id, user, updateIsAdmin: true).Result;

// Try to delete admin user
var result = _userController.DeleteUser(user.Id).Result;

// Should be forbidden
Assert.That(result, Is.InstanceOf<ForbidResult>());

// Verify user is not deleted
Assert.That(_userRepo.GetAllUsers().Result, Has.Count.EqualTo(1));
}

[Test]
public void TestGetUserProjects()
{
// Create a user and two projects
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
var project1 = _projectRepo.Create(new() { IsActive = false, Name = "Test Project 1" }).Result
?? throw new ProjectCreationException();
var project2 = _projectRepo.Create(new() { IsActive = true, Name = "Test Project 2" }).Result
?? throw new ProjectCreationException();

// Create user roles for both projects
var userRole1 = _userRoleRepo.Create(new() { ProjectId = project1.Id, Role = Role.Editor }).Result
?? throw new UserRoleCreationException();
var userRole2 = _userRoleRepo.Create(new() { ProjectId = project2.Id, Role = Role.Administrator }).Result
?? throw new UserRoleCreationException();

// Add roles to user
user.ProjectRoles[project1.Id] = userRole1.Id;
user.ProjectRoles[project2.Id] = userRole2.Id;
_ = _userRepo.Update(user.Id, user).Result;

// Get user projects
var result = (ObjectResult)_userController.GetUserProjects(user.Id).Result;
var projects = result.Value as List<UserProjectInfo>;

// Verify both projects are returned with correct roles
Assert.That(projects, Has.Count.EqualTo(2));
Assert.That(projects!.Exists(p => p.ProjectId == project1.Id && p.ProjectIsActive == project1.IsActive
&& p.ProjectName == project1.Name && p.Role == userRole1.Role));
Assert.That(projects.Exists(p => p.ProjectId == project2.Id && p.ProjectIsActive == project2.IsActive
&& p.ProjectName == project2.Name && p.Role == userRole2.Role));
}

[Test]
public void TestGetUserProjectsNoPermission()
{
_userController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();
var result = _userController.GetUserProjects("anything").Result;
Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public void TestGetUserProjectsNoUser()
{
var result = _userController.GetUserProjects("not-a-user").Result;
Assert.That(result, Is.InstanceOf<NotFoundResult>());
}

[Test]
public void TestIsEmailOrUsernameAvailable()
{
var user1 = RandomUser();
var user2 = RandomUser();
var email1 = user1.Email;
var email2 = user2.Email;
_userRepo.Create(user1);
_userRepo.Create(user2);
var email1 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException();
var email2 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException();

var result1 = (ObjectResult)_userController.IsEmailOrUsernameAvailable(email1.ToLowerInvariant()).Result;
Assert.That(result1.Value, Is.False);
Expand Down
2 changes: 2 additions & 0 deletions Backend.Tests/Mocks/ProjectRepositoryMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,6 @@ public Task<bool> CanImportLift(string projectId)
return Task.FromResult(project?.LiftImported != true);
}
}

internal sealed class ProjectCreationException : Exception;
}
2 changes: 2 additions & 0 deletions Backend.Tests/Mocks/UserEditRepositoryMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ public Task<bool> Replace(string projectId, string userEditId, UserEdit userEdit
return Task.FromResult(rmCount > 0);
}
}

internal sealed class UserEditCreationException : Exception;
}
2 changes: 2 additions & 0 deletions Backend.Tests/Mocks/UserRoleRepositoryMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,6 @@ public Task<ResultOfUpdate> Update(string userRoleId, UserRole userRole)
return Task.FromResult(ResultOfUpdate.Updated);
}
}

internal sealed class UserRoleCreationException : Exception;
}
1 change: 1 addition & 0 deletions Backend/Controllers/BannerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public async Task<IActionResult> GetBanner(BannerType type)
/// <summary>
/// Update the <see cref="Banner"/> with same <see cref="BannerType"/> as the given <see cref="SiteBanner"/>.
/// </summary>
/// <remarks> Can only be used by a site admin. </remarks>
[HttpPut("", Name = "UpdateBanner")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand Down
1 change: 1 addition & 0 deletions Backend/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public ProjectController(IProjectRepository projRepo, IUserRoleRepository userRo
}

/// <summary> Returns all <see cref="Project"/>s </summary>
/// <remarks> Can only be used by a site admin. </remarks>
[HttpGet(Name = "GetAllProjects")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<Project>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand Down
75 changes: 73 additions & 2 deletions Backend/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ namespace BackendFramework.Controllers
[Authorize]
[Produces("application/json")]
[Route("v1/users")]
public class UserController(
IUserRepository userRepo, ICaptchaService captchaService, IPermissionService permissionService) : Controller
public class UserController(IProjectRepository projectRepo, IUserRepository userRepo,
IUserEditRepository userEditRepo, IUserRoleRepository userRoleRepo, ICaptchaService captchaService,
IPermissionService permissionService) : Controller
{
private readonly IProjectRepository _projectRepo = projectRepo;
private readonly IUserRepository _userRepo = userRepo;
private readonly IUserEditRepository _userEditRepo = userEditRepo;
private readonly IUserRoleRepository _userRoleRepo = userRoleRepo;
private readonly ICaptchaService _captchaService = captchaService;
private readonly IPermissionService _permissionService = permissionService;

Expand All @@ -37,6 +41,7 @@ public async Task<IActionResult> VerifyCaptchaToken(string token)
}

/// <summary> Returns all <see cref="User"/>s </summary>
/// <remarks> Can only be used by a site admin. </remarks>
[HttpGet(Name = "GetAllUsers")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<User>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand Down Expand Up @@ -208,7 +213,51 @@ public async Task<IActionResult> UpdateUser(string userId, [FromBody, BindRequir
};
}

/// <summary> Gets project information for a user's roles. </summary>
/// <remarks> Can only be used by a site admin. </remarks>
[HttpGet("{userId}/projects", Name = "GetUserProjects")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<UserProjectInfo>))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUserProjects(string userId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting user projects");

if (!await _permissionService.IsSiteAdmin(HttpContext))
{
return Forbid();
}

var user = await _userRepo.GetUser(userId, sanitize: false);
if (user is null)
{
return NotFound();
}

var userProjects = new List<UserProjectInfo>();

foreach (var (projectId, userRoleId) in user.ProjectRoles)
{
var project = await _projectRepo.GetProject(projectId);
var userRole = await _userRoleRepo.GetUserRole(projectId, userRoleId);

if (project is not null && userRole is not null)
{
userProjects.Add(new UserProjectInfo
{
ProjectId = projectId,
ProjectIsActive = project.IsActive,
ProjectName = project.Name,
Role = userRole.Role
});
}
}

return Ok(userProjects);
}

/// <summary> Deletes <see cref="User"/> with specified id. </summary>
/// <remarks> Can only be used by a site admin. </remarks>
[HttpDelete("{userId}", Name = "DeleteUser")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand All @@ -222,6 +271,28 @@ public async Task<IActionResult> DeleteUser(string userId)
return Forbid();
}

// Ensure user exists and is not an admin
var user = await _userRepo.GetUser(userId);
if (user is null)
{
return NotFound();
}
if (user.IsAdmin)
{
return Forbid();
}

// Delete all UserEdits and UserRoles for this user
foreach (var (projectId, userEditId) in user.WorkedProjects)
{
await _userEditRepo.Delete(projectId, userEditId);
}
foreach (var (projectId, userRoleId) in user.ProjectRoles)
{
await _userRoleRepo.Delete(projectId, userRoleId);
}

// Finally, delete the user
return await _userRepo.Delete(userId) ? Ok() : NotFound();
}
}
Expand Down
16 changes: 16 additions & 0 deletions Backend/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ public class UserStub(User user)
public string? RoleId { get; set; }
}

/// <summary> Contains information about a user's role in a project. </summary>
public class UserProjectInfo
{
[Required]
public string ProjectId { get; set; } = "";

[Required]
public bool ProjectIsActive { get; set; } = true;

[Required]
public string ProjectName { get; set; } = "";

[Required]
public Role Role { get; set; } = Role.None;
}

/// <summary> Contains email/username and password for authentication. </summary>
/// <remarks>
/// This is used in a [FromBody] serializer, so its attributes cannot be set to readonly.
Expand Down
4 changes: 4 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@
"userList": "Users",
"deleteUser": {
"confirm": "Confirm deleting user from The Combine database.",
"loadingProjects": "Loading user projects...",
"projectsTitle": "This user has roles in the following projects:",
"noProjects": "This user has no project roles.",
"projectsLoadError": "Failed to load user projects.",
"toastSuccess": "User successfully deleted from The Combine.",
"toastFailure": "Failed to delete user from The Combine."
},
Expand Down
1 change: 1 addition & 0 deletions src/api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ models/status.ts
models/user-created-project.ts
models/user-edit-step-wrapper.ts
models/user-edit.ts
models/user-project-info.ts
models/user-role.ts
models/user-stub.ts
models/user.ts
Expand Down
Loading
Loading