Files
ColaFlow/colaflow-api/tests/ColaFlow.Application.Tests/Commands/UpdateStory/UpdateStoryCommandHandlerTests.cs
Yaojia Wang 6046bad12e fix(backend): Add explicit TenantId validation to Epic/Story/Task Query/Command Handlers
CRITICAL SECURITY FIX: Implemented Defense in Depth security pattern by adding
explicit TenantId verification to all Epic/Story/Task Query and Command Handlers.

Security Impact:
- BEFORE: Relied solely on EF Core global query filters (single layer)
- AFTER: Explicit TenantId validation + EF Core filters (defense in depth)

This ensures that even if EF Core query filters are accidentally disabled or bypassed,
tenant isolation is still maintained at the application layer.

Changes:

Query Handlers (6 handlers):
- GetEpicByIdQueryHandler: Added ITenantContext injection + explicit TenantId check
- GetStoryByIdQueryHandler: Added ITenantContext injection + explicit TenantId check
- GetTaskByIdQueryHandler: Added ITenantContext injection + explicit TenantId check
- GetEpicsByProjectIdQueryHandler: Verify Project.TenantId before querying Epics
- GetStoriesByEpicIdQueryHandler: Verify Epic.TenantId before querying Stories
- GetTasksByStoryIdQueryHandler: Verify Story.TenantId before querying Tasks

Command Handlers (5 handlers):
- UpdateEpicCommandHandler: Verify Project.TenantId before updating
- UpdateStoryCommandHandler: Verify Project.TenantId before updating
- UpdateTaskCommandHandler: Verify Project.TenantId before updating
- DeleteStoryCommandHandler: Verify Project.TenantId before deleting
- DeleteTaskCommandHandler: Verify Project.TenantId before deleting

Unit Tests:
- Updated 5 unit test files to mock ITenantContext
- All 32 unit tests passing
- All 7 multi-tenant isolation integration tests passing

Defense Layers (Security in Depth):
Layer 1: EF Core global query filters (database level)
Layer 2: Application-layer explicit TenantId validation (handler level)
Layer 3: Integration tests verifying tenant isolation (test level)

Test Results:
- Unit Tests: 32/32 PASSING
- Integration Tests: 7/7 PASSING (multi-tenant isolation)

This fix addresses a critical security vulnerability where we relied on a single
layer of defense (EF Core query filters) for tenant data isolation. Now we have
multiple layers ensuring no cross-tenant data leaks can occur.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 20:30:24 +01:00

128 lines
4.7 KiB
C#

using FluentAssertions;
using Moq;
using ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateStory;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
namespace ColaFlow.Application.Tests.Commands.UpdateStory;
public class UpdateStoryCommandHandlerTests
{
private readonly Mock<IProjectRepository> _projectRepositoryMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<ITenantContext> _tenantContextMock;
private readonly UpdateStoryCommandHandler _handler;
private readonly Guid _tenantId;
public UpdateStoryCommandHandlerTests()
{
_projectRepositoryMock = new Mock<IProjectRepository>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_tenantContextMock = new Mock<ITenantContext>();
_tenantId = Guid.NewGuid();
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
_handler = new UpdateStoryCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object, _tenantContextMock.Object);
}
[Fact]
public async Task Should_Update_Story_Successfully()
{
// Arrange
var userId = UserId.Create();
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
var story = epic.CreateStory("Original Title", "Original Description", TaskPriority.Low, userId);
var storyId = story.Id;
_projectRepositoryMock
.Setup(x => x.GetProjectWithStoryAsync(storyId, It.IsAny<CancellationToken>()))
.ReturnsAsync(project);
var command = new UpdateStoryCommand
{
StoryId = storyId.Value,
Title = "Updated Title",
Description = "Updated Description",
Status = "In Progress",
Priority = "High",
EstimatedHours = 16
};
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Title.Should().Be("Updated Title");
result.Description.Should().Be("Updated Description");
result.Status.Should().Be("In Progress");
result.Priority.Should().Be("High");
result.EstimatedHours.Should().Be(16);
_projectRepositoryMock.Verify(x => x.Update(project), Times.Once);
_unitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Should_Fail_When_Story_Not_Found()
{
// Arrange
var storyId = StoryId.Create();
_projectRepositoryMock
.Setup(x => x.GetProjectWithStoryAsync(storyId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Project?)null);
var command = new UpdateStoryCommand
{
StoryId = storyId.Value,
Title = "Updated Title",
Description = "Updated Description"
};
// Act
Func<Task> act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*Story*");
}
[Fact]
public async Task Should_Update_All_Fields_Correctly()
{
// Arrange
var userId = UserId.Create();
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
var story = epic.CreateStory("Original", "Original", TaskPriority.Low, userId);
_projectRepositoryMock
.Setup(x => x.GetProjectWithStoryAsync(story.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(project);
var command = new UpdateStoryCommand
{
StoryId = story.Id.Value,
Title = "New Title",
Description = "New Description",
Status = "Done",
Priority = "Urgent",
EstimatedHours = 24
};
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
story.Title.Should().Be("New Title");
story.Description.Should().Be("New Description");
story.Status.Should().Be(WorkItemStatus.Done);
story.Priority.Should().Be(TaskPriority.Urgent);
story.EstimatedHours.Should().Be(24);
}
}