Skip to content

Commit 66d5223

Browse files
committed
feat(taskitems): enforce non-project-manager assignee rule across create/update/patch with role-aware validation
1 parent d9be406 commit 66d5223

8 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/TaskManagement.Api/Features/TaskItems/Commands/Handlers/CreateTaskItemCommandHandler.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public async Task<TaskItemDto> Handle(CreateTaskItemCommand request, Cancellatio
7777
});
7878
}
7979

80+
await EnsureAssignableUserRoleAsync(assignedUserId, nameof(request.AssignedUserId), cancellationToken);
8081
EnsureProjectMember(_dbContext, project, assignedUserId, currentUserId);
8182
}
8283

@@ -110,6 +111,20 @@ public async Task<TaskItemDto> Handle(CreateTaskItemCommand request, Cancellatio
110111
return assignedUserId.Trim();
111112
}
112113

114+
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
115+
{
116+
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);
117+
var roles = userSummary?.Roles ?? Array.Empty<string>();
118+
119+
if (roles.Contains(Roles.ProjectManager))
120+
{
121+
throw new ValidationException(new[]
122+
{
123+
new ValidationFailure(propertyName, "Assigned user cannot have ProjectManager role.")
124+
});
125+
}
126+
}
127+
113128
private static void EnsureProjectMember(
114129
TaskManagementDbContext dbContext,
115130
Projects.Models.Project project,

src/TaskManagement.Api/Features/TaskItems/Commands/Handlers/PatchTaskItemCommandHandler.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ public async Task<TaskItemDto> Handle(PatchTaskItemCommand request, Cancellation
8989
});
9090
}
9191

92+
await EnsureAssignableUserRoleAsync(normalizedAssignedUserId, nameof(request.AssignedUserId), cancellationToken);
9293
EnsureProjectMember(_dbContext, taskItem.Project, normalizedAssignedUserId, currentUserId);
9394
taskItem.AssignedUserId = normalizedAssignedUserId;
9495
}
@@ -179,6 +180,20 @@ public async Task<TaskItemDto> Handle(PatchTaskItemCommand request, Cancellation
179180
return assignedUserId.Trim();
180181
}
181182

183+
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
184+
{
185+
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);
186+
var roles = userSummary?.Roles ?? Array.Empty<string>();
187+
188+
if (roles.Contains(Roles.ProjectManager))
189+
{
190+
throw new ValidationException(new[]
191+
{
192+
new ValidationFailure(propertyName, "Assigned user cannot have ProjectManager role.")
193+
});
194+
}
195+
}
196+
182197
private static void EnsureProjectMember(
183198
TaskManagementDbContext dbContext,
184199
Projects.Models.Project project,

src/TaskManagement.Api/Features/TaskItems/Commands/Handlers/UpdateTaskItemCommandHandler.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public async Task<TaskItemDto> Handle(UpdateTaskItemCommand request, Cancellatio
7676
});
7777
}
7878

79+
await EnsureAssignableUserRoleAsync(assignedUserId, nameof(request.AssignedUserId), cancellationToken);
7980
EnsureProjectMember(_dbContext, taskItem.Project, assignedUserId, currentUserId);
8081
}
8182

@@ -172,6 +173,20 @@ public async Task<TaskItemDto> Handle(UpdateTaskItemCommand request, Cancellatio
172173
return assignedUserId.Trim();
173174
}
174175

176+
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
177+
{
178+
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);
179+
var roles = userSummary?.Roles ?? Array.Empty<string>();
180+
181+
if (roles.Contains(Roles.ProjectManager))
182+
{
183+
throw new ValidationException(new[]
184+
{
185+
new ValidationFailure(propertyName, "Assigned user cannot have ProjectManager role.")
186+
});
187+
}
188+
}
189+
175190
private static void EnsureProjectMember(
176191
TaskManagementDbContext dbContext,
177192
Projects.Models.Project project,

src/TaskManagement.Api/Features/Users/Services/AuthUserDirectoryService.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ public async Task<bool> UserExistsAsync(string userId, CancellationToken cancell
8282
return new UserDirectorySummary
8383
{
8484
DisplayName = displayName,
85-
Email = user?.Email
85+
Email = user?.Email,
86+
Roles = user?.Roles?.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim()).ToList()
87+
?? []
8688
};
8789
}
8890

@@ -92,6 +94,7 @@ private sealed class UserSummaryResponse
9294
public string? UserName { get; set; }
9395
public string? Email { get; set; }
9496
public bool IsActive { get; set; }
97+
public List<string> Roles { get; set; } = [];
9598
}
9699
}
97100
}

src/TaskManagement.Api/Features/Users/Services/Models/UserDirectorySummary.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ public sealed class UserDirectorySummary
44
{
55
public string? DisplayName { get; init; }
66
public string? Email { get; init; }
7+
public IReadOnlyCollection<string> Roles { get; init; } = Array.Empty<string>();
78
}
89
}

tests/TaskManagement.Api.Tests/IntegrationTests/Features/Spa/SpaDashboardKanbanFlowTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public async Task SpaDashboardAndKanbanFlow_ShouldSupportTypicalProjectLifecycle
5353

5454
// 2) Project manager creates tasks that will populate Kanban columns.
5555
var todoTask = await CreateTaskAsync(projectId, "Task Todo", TaskStatus.Todo, MemberUserId);
56-
var inProgressTask = await CreateTaskAsync(projectId, "Task In Progress", TaskStatus.InProgress, ProjectManagerId);
56+
var inProgressTask = await CreateTaskAsync(projectId, "Task In Progress", TaskStatus.InProgress, MemberUserId);
5757
var doneTask = await CreateTaskAsync(projectId, "Task Done", TaskStatus.Done, MemberUserId);
5858

5959
// 3) Member user can fetch visible projects (project picker in SPA).
@@ -71,7 +71,7 @@ public async Task SpaDashboardAndKanbanFlow_ShouldSupportTypicalProjectLifecycle
7171
kanbanTasks.Should().NotBeNull();
7272
kanbanTasks!.Should().HaveCount(3);
7373
kanbanTasks.Should().Contain(t => t.Id == todoTask.Id && t.AssignedUserId == MemberUserId);
74-
kanbanTasks.Should().Contain(t => t.Id == inProgressTask.Id && t.AssignedUserId == ProjectManagerId);
74+
kanbanTasks.Should().Contain(t => t.Id == inProgressTask.Id && t.AssignedUserId == MemberUserId);
7575
kanbanTasks.Should().Contain(t => t.Id == doneTask.Id && t.Status == TaskStatus.Done);
7676

7777
// 5) Member updates one task status (typical drag-and-drop status move).
@@ -91,7 +91,7 @@ public async Task SpaDashboardAndKanbanFlow_ShouldSupportTypicalProjectLifecycle
9191
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<DashboardSummaryDto>();
9292
dashboard.Should().NotBeNull();
9393
dashboard!.ProjectsCount.Should().Be(1);
94-
dashboard.AssignedTasksCount.Should().Be(2);
94+
dashboard.AssignedTasksCount.Should().Be(3);
9595
dashboard.TasksClosedThisWeekCount.Should().Be(1);
9696
dashboard.OverdueAssignedTasksCount.Should().Be(0);
9797

tests/TaskManagement.Api.Tests/IntegrationTests/Features/TaskItems/CreateTaskItemEndpointTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ public async Task CreateTaskItem_WhenUserIsNotMemberOrOwner_ShouldReturnForbidde
204204
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
205205
}
206206

207+
[Fact]
208+
public async Task CreateTaskItem_WhenAssignedUserIsProjectManager_ShouldReturnBadRequest()
209+
{
210+
// Arrange
211+
SetAuthenticatedUser(_projectOwnerId);
212+
var command = new CreateTaskItemCommand
213+
{
214+
ProjectId = _project1Id,
215+
Title = "Task With Project Manager Assignee",
216+
AssignedUserId = "user-pm-create"
217+
};
218+
219+
// Act
220+
var response = await _client.PostAsJsonAsync("/api/taskitems", command);
221+
222+
// Assert
223+
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
224+
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
225+
problemDetails.Should().NotBeNull();
226+
problemDetails!.Errors.Should().ContainKey(nameof(CreateTaskItemCommand.AssignedUserId));
227+
}
228+
207229
[Fact]
208230
public async Task CreateTaskItem_ForNonExistentProject_ShouldReturnNotFound()
209231
{

tests/TaskManagement.Api.Tests/IntegrationTests/Fixtures/TestUserDirectoryService.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,25 @@ public Task<bool> UserExistsAsync(string userId, CancellationToken cancellationT
3030
return Task.FromResult<UserDirectorySummary?>(new UserDirectorySummary
3131
{
3232
DisplayName = $"Test User {userId}",
33-
Email = $"{userId}@example.test"
33+
Email = $"{userId}@example.test",
34+
Roles = ResolveRoles(userId)
3435
});
3536
}
37+
38+
private static IReadOnlyCollection<string> ResolveRoles(string userId)
39+
{
40+
if (userId.Contains("pm", StringComparison.OrdinalIgnoreCase)
41+
|| userId.Contains("project-manager", StringComparison.OrdinalIgnoreCase))
42+
{
43+
return ["ProjectManager"];
44+
}
45+
46+
if (userId.Contains("admin", StringComparison.OrdinalIgnoreCase))
47+
{
48+
return ["Administrator"];
49+
}
50+
51+
return ["User"];
52+
}
3653
}
3754
}

0 commit comments

Comments
 (0)