diff --git a/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs b/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs index 06b1247..7d38065 100644 --- a/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs +++ b/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs @@ -36,6 +36,13 @@ public static class ModuleExtensions options.UseNpgsql(connectionString)); } + // Register HTTP Context Accessor (for tenant context) + services.AddHttpContextAccessor(); + + // Register Tenant Context (for multi-tenant isolation) + services.AddScoped(); + // Register repositories services.AddScoped(); services.AddScoped(); diff --git a/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/Infrastructure/PMWebApplicationFactory.cs b/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/Infrastructure/PMWebApplicationFactory.cs index 4c1ecfc..2904948 100644 --- a/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/Infrastructure/PMWebApplicationFactory.cs +++ b/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/Infrastructure/PMWebApplicationFactory.cs @@ -45,6 +45,18 @@ public class PMWebApplicationFactory : WebApplicationFactory builder.ConfigureServices(services => { + // Remove existing DbContext registrations + var descriptorsToRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions)) + .ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + // Register test databases with In-Memory provider // Use the same database name for cross-context data consistency services.AddDbContext(options => diff --git a/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/MultiTenantIsolationTests.cs b/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/MultiTenantIsolationTests.cs new file mode 100644 index 0000000..c13641b --- /dev/null +++ b/colaflow-api/tests/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.IntegrationTests/MultiTenantIsolationTests.cs @@ -0,0 +1,307 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using ColaFlow.Modules.ProjectManagement.IntegrationTests.Infrastructure; +using ColaFlow.Modules.ProjectManagement.Application.DTOs; + +namespace ColaFlow.Modules.ProjectManagement.IntegrationTests; + +/// +/// CRITICAL: Multi-tenant isolation tests +/// These tests verify that tenant data is properly isolated +/// +public class MultiTenantIsolationTests : IClassFixture +{ + private readonly PMWebApplicationFactory _factory; + private readonly HttpClient _client; + + // Test tenant and user IDs + private readonly Guid _tenant1Id = Guid.NewGuid(); + private readonly Guid _tenant2Id = Guid.NewGuid(); + private readonly Guid _user1Id = Guid.NewGuid(); + private readonly Guid _user2Id = Guid.NewGuid(); + + public MultiTenantIsolationTests(PMWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + [Fact] + public async Task Project_Should_Be_Isolated_By_TenantId() + { + // Arrange: Tenant 1 creates a project + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + var createRequest = new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "This is tenant 1's project" + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", createRequest); + createResponse.EnsureSuccessStatusCode(); + + var project = await createResponse.Content.ReadFromJsonAsync(); + project.Should().NotBeNull(); + project!.Name.Should().Be("Tenant 1 Project"); + + // Act: Tenant 2 tries to access Tenant 1's project + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var getResponse = await _client.GetAsync($"/api/v1/projects/{project.Id}"); + + // Assert: Should return 404 (resource not found due to tenant filter) + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Tenant_Cannot_List_Other_Tenants_Projects() + { + // Arrange: Tenant 1 creates a project + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project A", + Key = "T1PA", + Description = "First project" + }); + + await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project B", + Key = "T1PB", + Description = "Second project" + }); + + // Arrange: Tenant 2 creates a project + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 2 Project", + Key = "T2P", + Description = "Tenant 2's project" + }); + + // Act: Tenant 2 lists projects + var listResponse = await _client.GetAsync("/api/v1/projects"); + listResponse.EnsureSuccessStatusCode(); + + var projects = await listResponse.Content.ReadFromJsonAsync>(); + + // Assert: Should only see Tenant 2's project + projects.Should().NotBeNull(); + projects!.Count.Should().Be(1); + projects[0].Name.Should().Be("Tenant 2 Project"); + } + + [Fact] + public async Task Epic_Should_Be_Isolated_By_TenantId() + { + // Arrange: Tenant 1 creates a project with an epic + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + // Create project + var projectResponse = await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "Test project" + }); + var project = await projectResponse.Content.ReadFromJsonAsync(); + + // Create epic + var epicResponse = await _client.PostAsJsonAsync("/api/v1/epics", new + { + ProjectId = project!.Id, + Name = "Tenant 1 Epic", + Description = "Test epic", + CreatedBy = _user1Id + }); + epicResponse.EnsureSuccessStatusCode(); + var epic = await epicResponse.Content.ReadFromJsonAsync(); + + // Act: Tenant 2 tries to access Tenant 1's epic + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var getResponse = await _client.GetAsync($"/api/v1/epics/{epic!.Id}"); + + // Assert: Should return 404 + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Story_Should_Be_Isolated_By_TenantId() + { + // Arrange: Tenant 1 creates project → epic → story + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + // Create project + var projectResponse = await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "Test project" + }); + var project = await projectResponse.Content.ReadFromJsonAsync(); + + // Create epic + var epicResponse = await _client.PostAsJsonAsync("/api/v1/epics", new + { + ProjectId = project!.Id, + Name = "Tenant 1 Epic", + Description = "Test epic", + CreatedBy = _user1Id + }); + var epic = await epicResponse.Content.ReadFromJsonAsync(); + + // Create story + var storyResponse = await _client.PostAsJsonAsync("/api/v1/stories", new + { + EpicId = epic!.Id, + Title = "Tenant 1 Story", + Description = "Test story", + Priority = "Medium", + CreatedBy = _user1Id + }); + storyResponse.EnsureSuccessStatusCode(); + var story = await storyResponse.Content.ReadFromJsonAsync(); + + // Act: Tenant 2 tries to access Tenant 1's story + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var getResponse = await _client.GetAsync($"/api/v1/stories/{story!.Id}"); + + // Assert: Should return 404 + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Task_Should_Be_Isolated_By_TenantId() + { + // Arrange: Tenant 1 creates project → epic → story → task + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + // Create project + var projectResponse = await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "Test project" + }); + var project = await projectResponse.Content.ReadFromJsonAsync(); + + // Create epic + var epicResponse = await _client.PostAsJsonAsync("/api/v1/epics", new + { + ProjectId = project!.Id, + Name = "Tenant 1 Epic", + Description = "Test epic", + CreatedBy = _user1Id + }); + var epic = await epicResponse.Content.ReadFromJsonAsync(); + + // Create story + var storyResponse = await _client.PostAsJsonAsync("/api/v1/stories", new + { + EpicId = epic!.Id, + Title = "Tenant 1 Story", + Description = "Test story", + Priority = "Medium", + CreatedBy = _user1Id + }); + var story = await storyResponse.Content.ReadFromJsonAsync(); + + // Create task + var taskResponse = await _client.PostAsJsonAsync("/api/v1/tasks", new + { + StoryId = story!.Id, + Title = "Tenant 1 Task", + Description = "Test task", + Priority = "Medium", + CreatedBy = _user1Id + }); + taskResponse.EnsureSuccessStatusCode(); + var task = await taskResponse.Content.ReadFromJsonAsync(); + + // Act: Tenant 2 tries to access Tenant 1's task + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var getResponse = await _client.GetAsync($"/api/v1/tasks/{task!.Id}"); + + // Assert: Should return 404 + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Tenant_Cannot_Update_Other_Tenants_Project() + { + // Arrange: Tenant 1 creates a project + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "Original description" + }); + var project = await createResponse.Content.ReadFromJsonAsync(); + + // Act: Tenant 2 tries to update Tenant 1's project + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var updateResponse = await _client.PutAsJsonAsync($"/api/v1/projects/{project!.Id}", new + { + Name = "Hacked Project Name", + Description = "Malicious update" + }); + + // Assert: Should return 404 (not 403, because tenant filter makes it invisible) + updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Tenant_Cannot_Delete_Other_Tenants_Project() + { + // Arrange: Tenant 1 creates a project + var tenant1Token = TestAuthHelper.GenerateJwtToken(_user1Id, _tenant1Id, "tenant1", "user1@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + + var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new + { + Name = "Tenant 1 Project", + Key = "T1P", + Description = "Important project" + }); + var project = await createResponse.Content.ReadFromJsonAsync(); + + // Act: Tenant 2 tries to delete Tenant 1's project + var tenant2Token = TestAuthHelper.GenerateJwtToken(_user2Id, _tenant2Id, "tenant2", "user2@test.com"); + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); + + var deleteResponse = await _client.DeleteAsync($"/api/v1/projects/{project!.Id}"); + + // Assert: Should return 404 + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Verify project still exists for Tenant 1 + _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant1Token); + var verifyResponse = await _client.GetAsync($"/api/v1/projects/{project.Id}"); + verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } +}