diff --git a/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs b/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs index 9fc1829..6531b20 100644 --- a/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs +++ b/colaflow-api/src/ColaFlow.API/Extensions/ModuleExtensions.cs @@ -75,6 +75,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/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Persistence/Repositories/IssueRepository.cs b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Persistence/Repositories/IssueRepository.cs index 9829e41..aaa584f 100644 --- a/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Persistence/Repositories/IssueRepository.cs +++ b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Persistence/Repositories/IssueRepository.cs @@ -2,33 +2,38 @@ using Microsoft.EntityFrameworkCore; using ColaFlow.Modules.IssueManagement.Domain.Entities; using ColaFlow.Modules.IssueManagement.Domain.Enums; using ColaFlow.Modules.IssueManagement.Domain.Repositories; +using ColaFlow.Modules.IssueManagement.Infrastructure.Services; namespace ColaFlow.Modules.IssueManagement.Infrastructure.Persistence.Repositories; /// -/// Repository implementation for Issue aggregate +/// Repository implementation for Issue aggregate with multi-tenant isolation /// public sealed class IssueRepository : IIssueRepository { private readonly IssueManagementDbContext _context; + private readonly ITenantContext _tenantContext; - public IssueRepository(IssueManagementDbContext context) + public IssueRepository(IssueManagementDbContext context, ITenantContext tenantContext) { _context = context; + _tenantContext = tenantContext; } + private Guid CurrentTenantId => _tenantContext.GetCurrentTenantId(); + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { return await _context.Issues .AsNoTracking() - .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); + .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == CurrentTenantId, cancellationToken); } public async Task> GetByProjectIdAsync(Guid projectId, CancellationToken cancellationToken = default) { return await _context.Issues .AsNoTracking() - .Where(i => i.ProjectId == projectId) + .Where(i => i.ProjectId == projectId && i.TenantId == CurrentTenantId) .OrderBy(i => i.CreatedAt) .ToListAsync(cancellationToken); } @@ -40,7 +45,7 @@ public sealed class IssueRepository : IIssueRepository { return await _context.Issues .AsNoTracking() - .Where(i => i.ProjectId == projectId && i.Status == status) + .Where(i => i.ProjectId == projectId && i.Status == status && i.TenantId == CurrentTenantId) .OrderBy(i => i.CreatedAt) .ToListAsync(cancellationToken); } @@ -49,7 +54,7 @@ public sealed class IssueRepository : IIssueRepository { return await _context.Issues .AsNoTracking() - .Where(i => i.AssigneeId == assigneeId) + .Where(i => i.AssigneeId == assigneeId && i.TenantId == CurrentTenantId) .OrderBy(i => i.CreatedAt) .ToListAsync(cancellationToken); } @@ -73,6 +78,6 @@ public sealed class IssueRepository : IIssueRepository public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) { - return await _context.Issues.AnyAsync(i => i.Id == id, cancellationToken); + return await _context.Issues.AnyAsync(i => i.Id == id && i.TenantId == CurrentTenantId, cancellationToken); } } diff --git a/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/ITenantContext.cs b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/ITenantContext.cs new file mode 100644 index 0000000..8fd3748 --- /dev/null +++ b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/ITenantContext.cs @@ -0,0 +1,9 @@ +namespace ColaFlow.Modules.IssueManagement.Infrastructure.Services; + +/// +/// Provides access to the current tenant context +/// +public interface ITenantContext +{ + Guid GetCurrentTenantId(); +} diff --git a/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/TenantContext.cs b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/TenantContext.cs new file mode 100644 index 0000000..71979a0 --- /dev/null +++ b/colaflow-api/src/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.Infrastructure/Services/TenantContext.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace ColaFlow.Modules.IssueManagement.Infrastructure.Services; + +/// +/// Implementation of ITenantContext that retrieves tenant ID from JWT claims +/// +public sealed class TenantContext : ITenantContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public TenantContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Guid GetCurrentTenantId() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + throw new InvalidOperationException("HTTP context is not available"); + + var user = httpContext.User; + var tenantClaim = user.FindFirst("tenant_id") ?? user.FindFirst("tenantId"); + + if (tenantClaim == null || !Guid.TryParse(tenantClaim.Value, out var tenantId)) + throw new UnauthorizedAccessException("Tenant ID not found in claims"); + + return tenantId; + } +} diff --git a/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/ColaFlow.Modules.IssueManagement.IntegrationTests.csproj b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/ColaFlow.Modules.IssueManagement.IntegrationTests.csproj new file mode 100644 index 0000000..fbc1fcd --- /dev/null +++ b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/ColaFlow.Modules.IssueManagement.IntegrationTests.csproj @@ -0,0 +1,49 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/IssueManagementWebApplicationFactory.cs b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/IssueManagementWebApplicationFactory.cs new file mode 100644 index 0000000..06c7d03 --- /dev/null +++ b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/IssueManagementWebApplicationFactory.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ColaFlow.Modules.Identity.Infrastructure.Persistence; +using ColaFlow.Modules.IssueManagement.Infrastructure.Persistence; +using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; + +namespace ColaFlow.Modules.IssueManagement.IntegrationTests.Infrastructure; + +/// +/// Custom WebApplicationFactory for Issue Management Integration Tests +/// Supports In-Memory database for fast, isolated tests +/// +public class IssueManagementWebApplicationFactory : WebApplicationFactory +{ + private readonly string _testDatabaseName = $"TestDb_{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Set environment to Testing + builder.UseEnvironment("Testing"); + + // Configure test-specific settings + builder.ConfigureAppConfiguration((context, config) => + { + // Clear existing connection strings to prevent PostgreSQL registration + config.Sources.Clear(); + + // Add minimal config for testing + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = "", + ["ConnectionStrings:PMDatabase"] = "", + ["ConnectionStrings:IMDatabase"] = "", + ["Jwt:SecretKey"] = "test-secret-key-for-integration-tests-minimum-32-characters", + ["Jwt:Issuer"] = "ColaFlow.Test", + ["Jwt:Audience"] = "ColaFlow.Test", + ["Jwt:AccessTokenExpirationMinutes"] = "15", + ["Jwt:RefreshTokenExpirationDays"] = "7" + }); + }); + + builder.ConfigureServices(services => + { + // Register test databases with In-Memory provider + // Use the same database name for cross-context data consistency + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_testDatabaseName); + options.EnableSensitiveDataLogging(); + }); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_testDatabaseName); + options.EnableSensitiveDataLogging(); + }); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_testDatabaseName); + options.EnableSensitiveDataLogging(); + }); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + + // Initialize databases after host is created + using var scope = host.Services.CreateScope(); + var services = scope.ServiceProvider; + + try + { + // Initialize Identity database + var identityDb = services.GetRequiredService(); + identityDb.Database.EnsureCreated(); + + // Initialize ProjectManagement database + var pmDb = services.GetRequiredService(); + pmDb.Database.EnsureCreated(); + + // Initialize IssueManagement database + var imDb = services.GetRequiredService(); + imDb.Database.EnsureCreated(); + } + catch (Exception ex) + { + Console.WriteLine($"Error initializing test database: {ex.Message}"); + throw; + } + + return host; + } +} diff --git a/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/TestAuthHelper.cs b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/TestAuthHelper.cs new file mode 100644 index 0000000..e2bea99 --- /dev/null +++ b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/Infrastructure/TestAuthHelper.cs @@ -0,0 +1,57 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace ColaFlow.Modules.IssueManagement.IntegrationTests.Infrastructure; + +/// +/// Helper class for generating JWT tokens in tests +/// +public static class TestAuthHelper +{ + private const string SecretKey = "test-secret-key-for-integration-tests-minimum-32-characters"; + private const string Issuer = "ColaFlow.Test"; + private const string Audience = "ColaFlow.Test"; + + public static string GenerateJwtToken(Guid userId, Guid tenantId, string tenantSlug, string email, string role = "Member") + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(SecretKey); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim("sub", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()), + new Claim("tenantId", tenantId.ToString()), + new Claim("tenantSlug", tenantSlug), + new Claim("email", email), + new Claim("role", role) + }), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = Issuer, + Audience = Audience, + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + public static void AddAuthHeader(this HttpClient client, string token) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + public static void AddAuthHeader(this HttpClient client, Guid userId, Guid tenantId, string tenantSlug, string email, string role = "Member") + { + var token = GenerateJwtToken(userId, tenantId, tenantSlug, email, role); + client.AddAuthHeader(token); + } +} diff --git a/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/IssueManagementIntegrationTests.cs b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/IssueManagementIntegrationTests.cs new file mode 100644 index 0000000..03e95f0 --- /dev/null +++ b/colaflow-api/tests/Modules/IssueManagement/ColaFlow.Modules.IssueManagement.IntegrationTests/IssueManagementIntegrationTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using ColaFlow.Modules.IssueManagement.Application.DTOs; +using ColaFlow.Modules.IssueManagement.Domain.Enums; +using ColaFlow.Modules.IssueManagement.IntegrationTests.Infrastructure; +using Xunit; + +namespace ColaFlow.Modules.IssueManagement.IntegrationTests; + +/// +/// Integration tests for Issue Management Module +/// Tests all CRUD operations, status changes, assignments, and multi-tenant isolation +/// +public class IssueManagementIntegrationTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly Guid _projectId = Guid.NewGuid(); + private readonly Guid _tenantId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + private readonly string _tenantSlug = "test-tenant"; + private readonly string _userEmail = "test@example.com"; + + public IssueManagementIntegrationTests(IssueManagementWebApplicationFactory factory) + { + _client = factory.CreateClient(); + _client.AddAuthHeader(_userId, _tenantId, _tenantSlug, _userEmail, "Member"); + } + + [Fact] + public async Task CreateIssue_WithStoryType_ShouldReturnCreatedIssue() + { + // Arrange + var request = new + { + Title = "Implement user authentication", + Description = "Add JWT authentication to the API", + Type = "Story", + Priority = "High" + }; + + // Act + var response = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var issue = await response.Content.ReadFromJsonAsync(); + issue.Should().NotBeNull(); + issue!.Title.Should().Be(request.Title); + issue.Description.Should().Be(request.Description); + issue.Type.Should().Be("Story"); + issue.Priority.Should().Be("High"); + issue.Status.Should().Be("Backlog"); + issue.ProjectId.Should().Be(_projectId); + issue.TenantId.Should().Be(_tenantId); + issue.ReporterId.Should().Be(_userId); + } + + [Fact] + public async Task CreateIssue_WithTaskType_ShouldReturnCreatedIssue() + { + // Arrange + var request = new + { + Title = "Write unit tests", + Description = "Add unit tests for Issue Management module", + Type = "Task", + Priority = "Medium" + }; + + // Act + var response = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var issue = await response.Content.ReadFromJsonAsync(); + issue.Should().NotBeNull(); + issue!.Type.Should().Be("Task"); + issue.Priority.Should().Be("Medium"); + } + + [Fact] + public async Task CreateIssue_WithBugType_ShouldReturnCreatedIssue() + { + // Arrange + var request = new + { + Title = "Fix login redirect issue", + Description = "Users are not redirected after login", + Type = "Bug", + Priority = "Critical" + }; + + // Act + var response = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var issue = await response.Content.ReadFromJsonAsync(); + issue.Should().NotBeNull(); + issue!.Type.Should().Be("Bug"); + issue.Priority.Should().Be("Critical"); + } + + [Fact] + public async Task GetIssueById_WithExistingIssue_ShouldReturnIssue() + { + // Arrange - Create an issue first + var createRequest = new + { + Title = "Test issue for retrieval", + Description = "This issue will be retrieved by ID", + Type = "Task", + Priority = "Low" + }; + var createResponse = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", createRequest); + var createdIssue = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.GetAsync($"/api/v1/projects/{_projectId}/issues/{createdIssue!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var issue = await response.Content.ReadFromJsonAsync(); + issue.Should().NotBeNull(); + issue!.Id.Should().Be(createdIssue.Id); + issue.Title.Should().Be(createRequest.Title); + } + + [Fact] + public async Task ListIssues_WithMultipleIssues_ShouldReturnAllIssues() + { + // Arrange - Create multiple issues + var issues = new[] + { + new { Title = "Issue 1", Description = "Description 1", Type = "Story", Priority = "High" }, + new { Title = "Issue 2", Description = "Description 2", Type = "Task", Priority = "Medium" }, + new { Title = "Issue 3", Description = "Description 3", Type = "Bug", Priority = "Low" } + }; + + foreach (var issueRequest in issues) + { + await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", issueRequest); + } + + // Act + var response = await _client.GetAsync($"/api/v1/projects/{_projectId}/issues"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var retrievedIssues = await response.Content.ReadFromJsonAsync>(); + retrievedIssues.Should().NotBeNull(); + retrievedIssues!.Count.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task ChangeIssueStatus_FromBacklogToInProgress_ShouldUpdateStatus() + { + // Arrange - Create an issue + var createRequest = new + { + Title = "Issue for status change", + Description = "This issue will have its status changed", + Type = "Story", + Priority = "High" + }; + var createResponse = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", createRequest); + var createdIssue = await createResponse.Content.ReadFromJsonAsync(); + + // Act - Change status to InProgress + var statusRequest = new + { + Status = "InProgress", + OldStatus = "Backlog" + }; + var response = await _client.PutAsJsonAsync( + $"/api/v1/projects/{_projectId}/issues/{createdIssue!.Id}/status", + statusRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify status was changed + var getResponse = await _client.GetAsync($"/api/v1/projects/{_projectId}/issues/{createdIssue.Id}"); + var updatedIssue = await getResponse.Content.ReadFromJsonAsync(); + updatedIssue.Should().NotBeNull(); + updatedIssue!.Status.Should().Be("InProgress"); + } + + [Fact] + public async Task AssignIssue_ToUser_ShouldUpdateAssignee() + { + // Arrange - Create an issue + var createRequest = new + { + Title = "Issue for assignment", + Description = "This issue will be assigned to a user", + Type = "Task", + Priority = "Medium" + }; + var createResponse = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", createRequest); + var createdIssue = await createResponse.Content.ReadFromJsonAsync(); + + var assigneeId = Guid.NewGuid(); + + // Act - Assign issue to user + var assignRequest = new { AssigneeId = assigneeId }; + var response = await _client.PutAsJsonAsync( + $"/api/v1/projects/{_projectId}/issues/{createdIssue!.Id}/assign", + assignRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify assignment + var getResponse = await _client.GetAsync($"/api/v1/projects/{_projectId}/issues/{createdIssue.Id}"); + var updatedIssue = await getResponse.Content.ReadFromJsonAsync(); + updatedIssue.Should().NotBeNull(); + updatedIssue!.AssigneeId.Should().Be(assigneeId); + } + + [Fact] + public async Task MultiTenantIsolation_DifferentTenant_ShouldNotAccessIssues() + { + // Arrange - Create an issue with tenant A + var createRequest = new + { + Title = "Tenant A issue", + Description = "This issue belongs to Tenant A", + Type = "Story", + Priority = "High" + }; + var createResponse = await _client.PostAsJsonAsync($"/api/v1/projects/{_projectId}/issues", createRequest); + var createdIssue = await createResponse.Content.ReadFromJsonAsync(); + + // Act - Try to access with tenant B credentials + var tenantBId = Guid.NewGuid(); + var tenantBClient = _client; + tenantBClient.DefaultRequestHeaders.Clear(); + tenantBClient.AddAuthHeader(_userId, tenantBId, "tenant-b", _userEmail, "Member"); + + var response = await tenantBClient.GetAsync($"/api/v1/projects/{_projectId}/issues/{createdIssue!.Id}"); + + // Assert - Tenant B should not see Tenant A's issue + // The issue will return NotFound or the global query filter will exclude it + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +}