Skip to content

Commit d256194

Browse files
committed
fix(task-assignment-rules): enforce self-assign/self-unassign for user role with updated tests
1 parent db8d01c commit d256194

8 files changed

Lines changed: 221 additions & 23 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public async Task<TaskItemDto> Handle(CreateTaskItemCommand request, Cancellatio
5454
}
5555

5656
var isAdmin = _currentUserService.IsInRole(Roles.Administrator);
57+
var isProjectManager = _currentUserService.IsInRole(Roles.ProjectManager);
5758
var isAuthorized = isAdmin
5859
|| project.OwnerUserId == currentUserId
5960
|| project.Members.Any(m => m.UserId == currentUserId);
@@ -65,6 +66,7 @@ public async Task<TaskItemDto> Handle(CreateTaskItemCommand request, Cancellatio
6566

6667
var taskItem = _mapper.Map<TaskItem>(request);
6768
var assignedUserId = NormalizeAssignedUserId(request.AssignedUserId);
69+
EnsureAssignmentChangeAllowedForCurrentUser(currentUserId, assignedUserId, isAdmin, isProjectManager);
6870
if (assignedUserId != null)
6971
{
7072
var userExists = await _userDirectoryService.UserExistsAsync(assignedUserId, cancellationToken);
@@ -111,6 +113,23 @@ public async Task<TaskItemDto> Handle(CreateTaskItemCommand request, Cancellatio
111113
return assignedUserId.Trim();
112114
}
113115

116+
private static void EnsureAssignmentChangeAllowedForCurrentUser(
117+
string currentUserId,
118+
string? newAssignedUserId,
119+
bool isAdmin,
120+
bool isProjectManager)
121+
{
122+
if (isAdmin || isProjectManager)
123+
{
124+
return;
125+
}
126+
127+
if (newAssignedUserId != null && !string.Equals(newAssignedUserId, currentUserId, StringComparison.Ordinal))
128+
{
129+
throw new ForbiddenAccessException("Users can only assign tasks to themselves.");
130+
}
131+
}
132+
114133
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
115134
{
116135
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ public async Task<TaskItemDto> Handle(PatchTaskItemCommand request, Cancellation
5555
}
5656

5757
var isAdmin = _currentUserService.IsInRole(Roles.Administrator);
58+
var isProjectManager = _currentUserService.IsInRole(Roles.ProjectManager);
5859
var isProjectMember = taskItem.Project.Members.Any(m => m.UserId == currentUserId);
5960
var isProjectOwner = taskItem.Project.OwnerUserId == currentUserId;
6061
var isAssignee = taskItem.AssignedUserId == currentUserId;
62+
var canManageAsProjectManager = isProjectManager && (isProjectOwner || isProjectMember);
6163

62-
if (!isAdmin && !isProjectOwner && !isAssignee && !isProjectMember)
64+
if (!isAdmin && !canManageAsProjectManager && !isProjectOwner && !isAssignee)
6365
{
6466
throw new ForbiddenAccessException("User is not authorized to update this task item.");
6567
}
@@ -73,6 +75,12 @@ public async Task<TaskItemDto> Handle(PatchTaskItemCommand request, Cancellation
7375
if (request.AssignedUserId.HasValue)
7476
{
7577
var normalizedAssignedUserId = NormalizeAssignedUserId(request.AssignedUserId.Value);
78+
EnsureAssignmentChangeAllowedForCurrentUser(
79+
currentUserId,
80+
taskItem.AssignedUserId,
81+
normalizedAssignedUserId,
82+
isAdmin,
83+
isProjectManager);
7684
if (normalizedAssignedUserId == null)
7785
{
7886
taskItem.AssignedUserId = null;
@@ -180,6 +188,34 @@ public async Task<TaskItemDto> Handle(PatchTaskItemCommand request, Cancellation
180188
return assignedUserId.Trim();
181189
}
182190

191+
private static void EnsureAssignmentChangeAllowedForCurrentUser(
192+
string currentUserId,
193+
string? currentAssignedUserId,
194+
string? newAssignedUserId,
195+
bool isAdmin,
196+
bool isProjectManager)
197+
{
198+
if (isAdmin || isProjectManager)
199+
{
200+
return;
201+
}
202+
203+
if (newAssignedUserId == null)
204+
{
205+
if (!string.Equals(currentAssignedUserId, currentUserId, StringComparison.Ordinal))
206+
{
207+
throw new ForbiddenAccessException("Users can only unassign tasks currently assigned to themselves.");
208+
}
209+
210+
return;
211+
}
212+
213+
if (!string.Equals(newAssignedUserId, currentUserId, StringComparison.Ordinal))
214+
{
215+
throw new ForbiddenAccessException("Users can only assign tasks to themselves.");
216+
}
217+
}
218+
183219
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
184220
{
185221
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,19 @@ public async Task<TaskItemDto> Handle(UpdateTaskItemCommand request, Cancellatio
5454
throw new NotFoundException(nameof(TaskItem), request.Id);
5555
}
5656
var isAdmin = _currentUserService.IsInRole(Roles.Administrator);
57+
var isProjectManager = _currentUserService.IsInRole(Roles.ProjectManager);
5758
var isProjectMember = taskItem.Project.Members.Any(m => m.UserId == currentUserId);
5859
bool isProjectOwner = taskItem.Project.OwnerUserId == currentUserId;
5960
bool isAssignee = taskItem.AssignedUserId == currentUserId;
61+
var canManageAsProjectManager = isProjectManager && (isProjectOwner || isProjectMember);
6062

61-
if (!isAdmin && !isProjectOwner && !isAssignee && !isProjectMember)
63+
if (!isAdmin && !canManageAsProjectManager && !isProjectOwner && !isAssignee)
6264
{
6365
throw new ForbiddenAccessException("User is not authorized to update this task item.");
6466
}
6567

6668
var assignedUserId = NormalizeAssignedUserId(request.AssignedUserId);
69+
EnsureAssignmentChangeAllowedForCurrentUser(currentUserId, taskItem.AssignedUserId, assignedUserId, isAdmin, isProjectManager);
6770
if (assignedUserId != null)
6871
{
6972
var userExists = await _userDirectoryService.UserExistsAsync(assignedUserId, cancellationToken);
@@ -173,6 +176,34 @@ public async Task<TaskItemDto> Handle(UpdateTaskItemCommand request, Cancellatio
173176
return assignedUserId.Trim();
174177
}
175178

179+
private static void EnsureAssignmentChangeAllowedForCurrentUser(
180+
string currentUserId,
181+
string? currentAssignedUserId,
182+
string? newAssignedUserId,
183+
bool isAdmin,
184+
bool isProjectManager)
185+
{
186+
if (isAdmin || isProjectManager)
187+
{
188+
return;
189+
}
190+
191+
if (newAssignedUserId == null)
192+
{
193+
if (!string.Equals(currentAssignedUserId, currentUserId, StringComparison.Ordinal))
194+
{
195+
throw new ForbiddenAccessException("Users can only unassign tasks currently assigned to themselves.");
196+
}
197+
198+
return;
199+
}
200+
201+
if (!string.Equals(newAssignedUserId, currentUserId, StringComparison.Ordinal))
202+
{
203+
throw new ForbiddenAccessException("Users can only assign tasks to themselves.");
204+
}
205+
}
206+
176207
private async Task EnsureAssignableUserRoleAsync(string assignedUserId, string propertyName, CancellationToken cancellationToken)
177208
{
178209
var userSummary = await _userDirectoryService.GetUserSummaryAsync(assignedUserId, cancellationToken);

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ private void SetAuthenticatedUser(string userId, string? roles = null)
7878
public async Task CreateTaskItem_WhenUserIsProjectOwner_ShouldReturnCreatedAndTaskDto()
7979
{
8080
// Arrange
81-
SetAuthenticatedUser(_projectOwnerId);
81+
SetAuthenticatedUser(_projectOwnerId, Roles.ProjectManager);
8282
var command = new CreateTaskItemCommand
8383
{
8484
ProjectId = _project1Id,
@@ -136,6 +136,25 @@ public async Task CreateTaskItem_WhenUserIsProjectMember_ShouldReturnCreatedAndT
136136
createdDto.CreatedByUserId.Should().Be(_projectMemberId);
137137
}
138138

139+
[Fact]
140+
public async Task CreateTaskItem_WhenUserAssignsTaskToAnotherUser_ShouldReturnForbidden()
141+
{
142+
// Arrange
143+
SetAuthenticatedUser(_projectMemberId, Roles.User);
144+
var command = new CreateTaskItemCommand
145+
{
146+
ProjectId = _project1Id,
147+
Title = "Invalid assignment",
148+
AssignedUserId = _unrelatedUserId
149+
};
150+
151+
// Act
152+
var response = await _client.PostAsJsonAsync("/api/taskitems", command);
153+
154+
// Assert
155+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
156+
}
157+
139158
[Fact]
140159
public async Task CreateTaskItem_WhenUserIsAdministrator_ShouldReturnCreatedAndTaskDto()
141160
{
@@ -164,7 +183,7 @@ public async Task CreateTaskItem_WhenUserIsAdministrator_ShouldReturnCreatedAndT
164183
public async Task CreateTaskItem_ShouldAutoAddMember_WhenAssigneeIsNotProjectMember()
165184
{
166185
// Arrange
167-
SetAuthenticatedUser(_projectOwnerId);
186+
SetAuthenticatedUser(_projectOwnerId, Roles.ProjectManager);
168187
var newAssigneeId = "user-task-new-member-create";
169188
var command = new CreateTaskItemCommand
170189
{
@@ -208,7 +227,7 @@ public async Task CreateTaskItem_WhenUserIsNotMemberOrOwner_ShouldReturnForbidde
208227
public async Task CreateTaskItem_WhenAssignedUserIsProjectManager_ShouldReturnBadRequest()
209228
{
210229
// Arrange
211-
SetAuthenticatedUser(_projectOwnerId);
230+
SetAuthenticatedUser(_projectOwnerId, Roles.ProjectManager);
212231
var command = new CreateTaskItemCommand
213232
{
214233
ProjectId = _project1Id,

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ await _factory.SeedDatabaseAsync(db =>
7272
[Fact]
7373
public async Task PatchTaskItem_ShouldUpdateStatusAndCreateActivityLog()
7474
{
75-
SetAuthenticatedUser(_ownerUserId);
75+
SetAuthenticatedUser(_ownerUserId, Roles.ProjectManager);
7676
var command = new PatchTaskItemCommand { Status = TaskStatus.Done };
7777

7878
var response = await _client.PatchAsJsonAsync($"/api/taskitems/{_taskId}", command);
@@ -91,7 +91,7 @@ public async Task PatchTaskItem_ShouldUpdateStatusAndCreateActivityLog()
9191
[Fact]
9292
public async Task PatchTaskItem_ClearAssignedUser_ShouldSetNull()
9393
{
94-
SetAuthenticatedUser(_ownerUserId);
94+
SetAuthenticatedUser(_ownerUserId, Roles.ProjectManager);
9595
var command = new PatchTaskItemCommand { AssignedUserId = null };
9696

9797
var response = await _client.PatchAsJsonAsync($"/api/taskitems/{_taskId}", command);
@@ -139,12 +139,63 @@ public async Task PatchTaskItem_WhenUserIsNotAuthorized_ShouldReturnForbidden()
139139
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
140140
}
141141

142-
private void SetAuthenticatedUser(string userId)
142+
[Fact]
143+
public async Task PatchTaskItem_WhenProjectMemberIsNotAssignee_ShouldReturnForbidden()
144+
{
145+
SetAuthenticatedUser(_otherUserId);
146+
await _factory.SeedDatabaseAsync(db =>
147+
{
148+
var projectMember = new ProjectMember
149+
{
150+
ProjectId = _projectId,
151+
UserId = _otherUserId,
152+
AddedByUserId = _ownerUserId,
153+
JoinedAt = DateTime.UtcNow
154+
};
155+
156+
db.ProjectMembers.Add(projectMember);
157+
return Task.CompletedTask;
158+
});
159+
160+
var command = new PatchTaskItemCommand { Title = "Member cannot edit another user's task" };
161+
var response = await _client.PatchAsJsonAsync($"/api/taskitems/{_taskId}", command);
162+
163+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
164+
}
165+
166+
[Fact]
167+
public async Task PatchTaskItem_WhenAssigneeUserUnassignsSelf_ShouldReturnOk()
168+
{
169+
SetAuthenticatedUser(_memberUserId);
170+
var command = new PatchTaskItemCommand { AssignedUserId = null };
171+
172+
var response = await _client.PatchAsJsonAsync($"/api/taskitems/{_taskId}", command);
173+
174+
response.StatusCode.Should().Be(HttpStatusCode.OK);
175+
var dto = await response.Content.ReadFromJsonAsync<TaskItemDto>();
176+
dto.Should().NotBeNull();
177+
dto!.AssignedUserId.Should().BeNull();
178+
}
179+
180+
[Fact]
181+
public async Task PatchTaskItem_WhenUserAssignsTaskToAnotherUser_ShouldReturnForbidden()
182+
{
183+
SetAuthenticatedUser(_memberUserId);
184+
var command = new PatchTaskItemCommand { AssignedUserId = _otherUserId };
185+
186+
var response = await _client.PatchAsJsonAsync($"/api/taskitems/{_taskId}", command);
187+
188+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
189+
}
190+
191+
private void SetAuthenticatedUser(string userId, string? roles = null)
143192
{
144193
_client.DefaultRequestHeaders.Remove(TestAuthenticationHandler.TestUserIdHeader);
145194
_client.DefaultRequestHeaders.Remove(TestAuthenticationHandler.TestUserRolesHeader);
146195
_client.DefaultRequestHeaders.Add(TestAuthenticationHandler.TestUserIdHeader, userId);
147-
_client.DefaultRequestHeaders.Add(TestAuthenticationHandler.TestUserRolesHeader, Roles.User);
196+
_client.DefaultRequestHeaders.Add(
197+
TestAuthenticationHandler.TestUserRolesHeader,
198+
string.IsNullOrWhiteSpace(roles) ? Roles.User : roles);
148199
}
149200
}
150201
}

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,21 @@ await _factory.SeedDatabaseAsync(db =>
107107

108108
public Task DisposeAsync() => Task.CompletedTask;
109109

110-
private void SetAuthenticatedUser(string userId)
110+
private void SetAuthenticatedUser(string userId, string? roles = null)
111111
{
112112
_client.DefaultRequestHeaders.Remove(TestAuthenticationHandler.TestUserIdHeader);
113113
_client.DefaultRequestHeaders.Remove(TestAuthenticationHandler.TestUserRolesHeader);
114114
_client.DefaultRequestHeaders.Add(TestAuthenticationHandler.TestUserIdHeader, userId);
115-
_client.DefaultRequestHeaders.Add(TestAuthenticationHandler.TestUserRolesHeader, Roles.User);
115+
_client.DefaultRequestHeaders.Add(
116+
TestAuthenticationHandler.TestUserRolesHeader,
117+
string.IsNullOrWhiteSpace(roles) ? Roles.User : roles);
116118
}
117119

118120
[Fact]
119121
public async Task UpdateTaskItem_WhenUserIsProjectOwner_ShouldReturnOkAndUpdatedDto()
120122
{
121123
// Arrange
122-
SetAuthenticatedUser(_projectOwnerId);
124+
SetAuthenticatedUser(_projectOwnerId, Roles.ProjectManager);
123125
var command = new UpdateTaskItemCommand
124126
{
125127
Id = _taskIdToUpdate,
@@ -190,7 +192,7 @@ public async Task UpdateTaskItem_WhenUserIsAssignee_ShouldReturnOkAndUpdatedDto(
190192
public async Task UpdateTaskItem_ShouldAutoAddMember_WhenAssigneeIsNotProjectMember()
191193
{
192194
// Arrange
193-
SetAuthenticatedUser(_projectOwnerId);
195+
SetAuthenticatedUser(_projectOwnerId, Roles.ProjectManager);
194196
var newAssigneeId = "user-task-new-member-update";
195197
var command = new UpdateTaskItemCommand
196198
{
@@ -217,17 +219,42 @@ public async Task UpdateTaskItem_ShouldAutoAddMember_WhenAssigneeIsNotProjectMem
217219
}
218220

219221
[Fact]
220-
public async Task UpdateTaskItem_WhenUserIsProjectMemberButNotAssigneeOrOwner_ShouldReturnOk()
222+
public async Task UpdateTaskItem_WhenUserIsProjectMemberButNotAssigneeOrOwner_ShouldReturnForbidden()
221223
{
222224
// Arrange
223225
SetAuthenticatedUser(_projectMemberNotAssigneeId);
224-
var command = new UpdateTaskItemCommand { Id = _taskIdToUpdate, Title = "Member Update Attempt" };
226+
var command = new UpdateTaskItemCommand
227+
{
228+
Id = _taskIdToUpdate,
229+
Title = "Member Update Attempt",
230+
AssignedUserId = _projectMemberNotAssigneeId
231+
};
225232

226233
// Act
227234
var response = await _client.PutAsJsonAsync($"/api/taskitems/{_taskIdToUpdate}", command);
228235

229236
// Assert Response
230-
response.StatusCode.Should().Be(HttpStatusCode.OK);
237+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
238+
}
239+
240+
[Fact]
241+
public async Task UpdateTaskItem_WhenRegularUserAssignsTaskToAnotherUser_ShouldReturnForbidden()
242+
{
243+
// Arrange
244+
SetAuthenticatedUser(_projectMemberNotAssigneeId);
245+
var command = new UpdateTaskItemCommand
246+
{
247+
Id = _taskIdToUpdate,
248+
Title = "Attempt Assign Other User",
249+
Status = TaskStatus.InProgress,
250+
AssignedUserId = _taskAssigneeId
251+
};
252+
253+
// Act
254+
var response = await _client.PutAsJsonAsync($"/api/taskitems/{_taskIdToUpdate}", command);
255+
256+
// Assert
257+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
231258
}
232259

233260
[Fact]

tests/TaskManagement.Api.Tests/UnitTests/Features/TaskItems/Commands/CreateTaskItemCommandHandlerTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ public async Task Handle_ShouldThrowValidationException_WhenAssignedUserDoesNotE
215215
AssignedUserId = "invalid-user"
216216
};
217217
_mockCurrentUser.Setup(u => u.Id).Returns(_projectOwnerId);
218+
_mockCurrentUser.Setup(u => u.IsInRole(Roles.ProjectManager)).Returns(true);
218219
_mockUserDirectory
219220
.Setup(s => s.UserExistsAsync(command.AssignedUserId!, It.IsAny<CancellationToken>()))
220221
.ReturnsAsync(false);
@@ -241,6 +242,7 @@ public async Task Handle_ShouldAutoAddMember_WhenAssignedUserIsNotProjectMember(
241242
AssignedUserId = newAssigneeId
242243
};
243244
_mockCurrentUser.Setup(u => u.Id).Returns(_projectOwnerId);
245+
_mockCurrentUser.Setup(u => u.IsInRole(Roles.ProjectManager)).Returns(true);
244246
_mockUserDirectory
245247
.Setup(s => s.UserExistsAsync(newAssigneeId, It.IsAny<CancellationToken>()))
246248
.ReturnsAsync(true);

0 commit comments

Comments
 (0)