Skip to content

Commit a49f082

Browse files
Implement cascading deletion of user roles and edits with project visibility (#3971)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Danny Rorabaugh <imnasnainaec@gmail.com>
1 parent 9734714 commit a49f082

File tree

15 files changed

+451
-27
lines changed

15 files changed

+451
-27
lines changed

Backend.Tests/Controllers/UserControllerTests.cs

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ namespace Backend.Tests.Controllers
1111
{
1212
internal sealed class UserControllerTests : IDisposable
1313
{
14+
private IProjectRepository _projectRepo = null!;
1415
private IUserRepository _userRepo = null!;
16+
private IUserEditRepository _userEditRepo = null!;
17+
private IUserRoleRepository _userRoleRepo = null!;
1518
private UserController _userController = null!;
1619

1720
public void Dispose()
@@ -23,9 +26,12 @@ public void Dispose()
2326
[SetUp]
2427
public void Setup()
2528
{
29+
_projectRepo = new ProjectRepositoryMock();
2630
_userRepo = new UserRepositoryMock();
27-
_userController = new UserController(
28-
_userRepo, new CaptchaServiceMock(), new PermissionServiceMock(_userRepo));
31+
_userEditRepo = new UserEditRepositoryMock();
32+
_userRoleRepo = new UserRoleRepositoryMock();
33+
_userController = new UserController(_projectRepo, _userRepo, _userEditRepo, _userRoleRepo,
34+
new CaptchaServiceMock(), new PermissionServiceMock(_userRepo));
2935
}
3036

3137
private static User RandomUser()
@@ -171,8 +177,7 @@ public void TestCreateUser()
171177
[Test]
172178
public void TestCreateUserBadUsername()
173179
{
174-
var user = RandomUser();
175-
_userRepo.Create(user);
180+
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
176181

177182
var user2 = RandomUser();
178183
user2.Username = " ";
@@ -186,8 +191,7 @@ public void TestCreateUserBadUsername()
186191
[Test]
187192
public void TestCreateUserBadEmail()
188193
{
189-
var user = RandomUser();
190-
_userRepo.Create(user);
194+
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
191195

192196
var user2 = RandomUser();
193197
user2.Email = " ";
@@ -259,15 +263,109 @@ public void TestDeleteUserNoPermission()
259263
Assert.That(result, Is.InstanceOf<ForbidResult>());
260264
}
261265

266+
[Test]
267+
public void TestDeleteUserWithRolesAndEdits()
268+
{
269+
// Create a user, project, user role, and user edit
270+
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
271+
var project = _projectRepo.Create(new() { Name = "Test Project" }).Result
272+
?? throw new ProjectCreationException();
273+
var userRole = _userRoleRepo.Create(new() { ProjectId = project.Id, Role = Role.Editor }).Result
274+
?? throw new UserRoleCreationException();
275+
var userEdit = _userEditRepo.Create(new() { ProjectId = project.Id }).Result
276+
?? throw new UserEditCreationException();
277+
278+
// Add role and edit to user
279+
user.ProjectRoles[project.Id] = userRole.Id;
280+
user.WorkedProjects[project.Id] = userEdit.Id;
281+
_ = _userRepo.Update(user.Id, user).Result;
282+
283+
// Verify they exist
284+
Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Not.Null);
285+
Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Not.Null);
286+
287+
// Delete the user
288+
_ = _userController.DeleteUser(user.Id).Result;
289+
290+
// Verify user is deleted
291+
Assert.That(_userRepo.GetAllUsers().Result, Is.Empty);
292+
293+
// Verify user role and edit are deleted
294+
Assert.That(_userRoleRepo.GetUserRole(project.Id, userRole.Id).Result, Is.Null);
295+
Assert.That(_userEditRepo.GetUserEdit(project.Id, userEdit.Id).Result, Is.Null);
296+
}
297+
298+
[Test]
299+
public void TestDeleteAdminUser()
300+
{
301+
// Create an admin user
302+
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
303+
user.IsAdmin = true;
304+
_ = _userRepo.Update(user.Id, user, updateIsAdmin: true).Result;
305+
306+
// Try to delete admin user
307+
var result = _userController.DeleteUser(user.Id).Result;
308+
309+
// Should be forbidden
310+
Assert.That(result, Is.InstanceOf<ForbidResult>());
311+
312+
// Verify user is not deleted
313+
Assert.That(_userRepo.GetAllUsers().Result, Has.Count.EqualTo(1));
314+
}
315+
316+
[Test]
317+
public void TestGetUserProjects()
318+
{
319+
// Create a user and two projects
320+
var user = _userRepo.Create(RandomUser()).Result ?? throw new UserCreationException();
321+
var project1 = _projectRepo.Create(new() { IsActive = false, Name = "Test Project 1" }).Result
322+
?? throw new ProjectCreationException();
323+
var project2 = _projectRepo.Create(new() { IsActive = true, Name = "Test Project 2" }).Result
324+
?? throw new ProjectCreationException();
325+
326+
// Create user roles for both projects
327+
var userRole1 = _userRoleRepo.Create(new() { ProjectId = project1.Id, Role = Role.Editor }).Result
328+
?? throw new UserRoleCreationException();
329+
var userRole2 = _userRoleRepo.Create(new() { ProjectId = project2.Id, Role = Role.Administrator }).Result
330+
?? throw new UserRoleCreationException();
331+
332+
// Add roles to user
333+
user.ProjectRoles[project1.Id] = userRole1.Id;
334+
user.ProjectRoles[project2.Id] = userRole2.Id;
335+
_ = _userRepo.Update(user.Id, user).Result;
336+
337+
// Get user projects
338+
var result = (ObjectResult)_userController.GetUserProjects(user.Id).Result;
339+
var projects = result.Value as List<UserProjectInfo>;
340+
341+
// Verify both projects are returned with correct roles
342+
Assert.That(projects, Has.Count.EqualTo(2));
343+
Assert.That(projects!.Exists(p => p.ProjectId == project1.Id && p.ProjectIsActive == project1.IsActive
344+
&& p.ProjectName == project1.Name && p.Role == userRole1.Role));
345+
Assert.That(projects.Exists(p => p.ProjectId == project2.Id && p.ProjectIsActive == project2.IsActive
346+
&& p.ProjectName == project2.Name && p.Role == userRole2.Role));
347+
}
348+
349+
[Test]
350+
public void TestGetUserProjectsNoPermission()
351+
{
352+
_userController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();
353+
var result = _userController.GetUserProjects("anything").Result;
354+
Assert.That(result, Is.InstanceOf<ForbidResult>());
355+
}
356+
357+
[Test]
358+
public void TestGetUserProjectsNoUser()
359+
{
360+
var result = _userController.GetUserProjects("not-a-user").Result;
361+
Assert.That(result, Is.InstanceOf<NotFoundResult>());
362+
}
363+
262364
[Test]
263365
public void TestIsEmailOrUsernameAvailable()
264366
{
265-
var user1 = RandomUser();
266-
var user2 = RandomUser();
267-
var email1 = user1.Email;
268-
var email2 = user2.Email;
269-
_userRepo.Create(user1);
270-
_userRepo.Create(user2);
367+
var email1 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException();
368+
var email2 = _userRepo.Create(RandomUser()).Result?.Email ?? throw new UserCreationException();
271369

272370
var result1 = (ObjectResult)_userController.IsEmailOrUsernameAvailable(email1.ToLowerInvariant()).Result;
273371
Assert.That(result1.Value, Is.False);

Backend.Tests/Mocks/ProjectRepositoryMock.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,6 @@ public Task<bool> CanImportLift(string projectId)
9797
return Task.FromResult(project?.LiftImported != true);
9898
}
9999
}
100+
101+
internal sealed class ProjectCreationException : Exception;
100102
}

Backend.Tests/Mocks/UserEditRepositoryMock.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ public Task<bool> Replace(string projectId, string userEditId, UserEdit userEdit
6161
return Task.FromResult(rmCount > 0);
6262
}
6363
}
64+
65+
internal sealed class UserEditCreationException : Exception;
6466
}

Backend.Tests/Mocks/UserRoleRepositoryMock.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,6 @@ public Task<ResultOfUpdate> Update(string userRoleId, UserRole userRole)
6868
return Task.FromResult(ResultOfUpdate.Updated);
6969
}
7070
}
71+
72+
internal sealed class UserRoleCreationException : Exception;
7173
}

Backend/Controllers/BannerController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public async Task<IActionResult> GetBanner(BannerType type)
4242
/// <summary>
4343
/// Update the <see cref="Banner"/> with same <see cref="BannerType"/> as the given <see cref="SiteBanner"/>.
4444
/// </summary>
45+
/// <remarks> Can only be used by a site admin. </remarks>
4546
[HttpPut("", Name = "UpdateBanner")]
4647
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))]
4748
[ProducesResponseType(StatusCodes.Status403Forbidden)]

Backend/Controllers/ProjectController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public ProjectController(IProjectRepository projRepo, IUserRoleRepository userRo
3434
}
3535

3636
/// <summary> Returns all <see cref="Project"/>s </summary>
37+
/// <remarks> Can only be used by a site admin. </remarks>
3738
[HttpGet(Name = "GetAllProjects")]
3839
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<Project>))]
3940
[ProducesResponseType(StatusCodes.Status403Forbidden)]

Backend/Controllers/UserController.cs

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ namespace BackendFramework.Controllers
1515
[Authorize]
1616
[Produces("application/json")]
1717
[Route("v1/users")]
18-
public class UserController(
19-
IUserRepository userRepo, ICaptchaService captchaService, IPermissionService permissionService) : Controller
18+
public class UserController(IProjectRepository projectRepo, IUserRepository userRepo,
19+
IUserEditRepository userEditRepo, IUserRoleRepository userRoleRepo, ICaptchaService captchaService,
20+
IPermissionService permissionService) : Controller
2021
{
22+
private readonly IProjectRepository _projectRepo = projectRepo;
2123
private readonly IUserRepository _userRepo = userRepo;
24+
private readonly IUserEditRepository _userEditRepo = userEditRepo;
25+
private readonly IUserRoleRepository _userRoleRepo = userRoleRepo;
2226
private readonly ICaptchaService _captchaService = captchaService;
2327
private readonly IPermissionService _permissionService = permissionService;
2428

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

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

216+
/// <summary> Gets project information for a user's roles. </summary>
217+
/// <remarks> Can only be used by a site admin. </remarks>
218+
[HttpGet("{userId}/projects", Name = "GetUserProjects")]
219+
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<UserProjectInfo>))]
220+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
221+
[ProducesResponseType(StatusCodes.Status404NotFound)]
222+
public async Task<IActionResult> GetUserProjects(string userId)
223+
{
224+
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting user projects");
225+
226+
if (!await _permissionService.IsSiteAdmin(HttpContext))
227+
{
228+
return Forbid();
229+
}
230+
231+
var user = await _userRepo.GetUser(userId, sanitize: false);
232+
if (user is null)
233+
{
234+
return NotFound();
235+
}
236+
237+
var userProjects = new List<UserProjectInfo>();
238+
239+
foreach (var (projectId, userRoleId) in user.ProjectRoles)
240+
{
241+
var project = await _projectRepo.GetProject(projectId);
242+
var userRole = await _userRoleRepo.GetUserRole(projectId, userRoleId);
243+
244+
if (project is not null && userRole is not null)
245+
{
246+
userProjects.Add(new UserProjectInfo
247+
{
248+
ProjectId = projectId,
249+
ProjectIsActive = project.IsActive,
250+
ProjectName = project.Name,
251+
Role = userRole.Role
252+
});
253+
}
254+
}
255+
256+
return Ok(userProjects);
257+
}
258+
211259
/// <summary> Deletes <see cref="User"/> with specified id. </summary>
260+
/// <remarks> Can only be used by a site admin. </remarks>
212261
[HttpDelete("{userId}", Name = "DeleteUser")]
213262
[ProducesResponseType(StatusCodes.Status200OK)]
214263
[ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -222,6 +271,28 @@ public async Task<IActionResult> DeleteUser(string userId)
222271
return Forbid();
223272
}
224273

274+
// Ensure user exists and is not an admin
275+
var user = await _userRepo.GetUser(userId);
276+
if (user is null)
277+
{
278+
return NotFound();
279+
}
280+
if (user.IsAdmin)
281+
{
282+
return Forbid();
283+
}
284+
285+
// Delete all UserEdits and UserRoles for this user
286+
foreach (var (projectId, userEditId) in user.WorkedProjects)
287+
{
288+
await _userEditRepo.Delete(projectId, userEditId);
289+
}
290+
foreach (var (projectId, userRoleId) in user.ProjectRoles)
291+
{
292+
await _userRoleRepo.Delete(projectId, userRoleId);
293+
}
294+
295+
// Finally, delete the user
225296
return await _userRepo.Delete(userId) ? Ok() : NotFound();
226297
}
227298
}

Backend/Models/User.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ public class UserStub(User user)
138138
public string? RoleId { get; set; }
139139
}
140140

141+
/// <summary> Contains information about a user's role in a project. </summary>
142+
public class UserProjectInfo
143+
{
144+
[Required]
145+
public string ProjectId { get; set; } = "";
146+
147+
[Required]
148+
public bool ProjectIsActive { get; set; } = true;
149+
150+
[Required]
151+
public string ProjectName { get; set; } = "";
152+
153+
[Required]
154+
public Role Role { get; set; } = Role.None;
155+
}
156+
141157
/// <summary> Contains email/username and password for authentication. </summary>
142158
/// <remarks>
143159
/// This is used in a [FromBody] serializer, so its attributes cannot be set to readonly.

public/locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@
133133
"userList": "Users",
134134
"deleteUser": {
135135
"confirm": "Confirm deleting user from The Combine database.",
136+
"loadingProjects": "Loading user projects...",
137+
"projectsTitle": "This user has roles in the following projects:",
138+
"noProjects": "This user has no project roles.",
139+
"projectsLoadError": "Failed to load user projects.",
136140
"toastSuccess": "User successfully deleted from The Combine.",
137141
"toastFailure": "Failed to delete user from The Combine."
138142
},

src/api/.openapi-generator/FILES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ models/status.ts
6363
models/user-created-project.ts
6464
models/user-edit-step-wrapper.ts
6565
models/user-edit.ts
66+
models/user-project-info.ts
6667
models/user-role.ts
6768
models/user-stub.ts
6869
models/user.ts

0 commit comments

Comments
 (0)