test(backend): Add Issue Management integration tests + fix multi-tenant isolation

Created comprehensive integration test suite for Issue Management Module with 8 test cases covering all CRUD operations, status changes, assignments, and multi-tenant isolation.

Test Cases (8/8):
1. Create Issue (Story type)
2. Create Issue (Task type)
3. Create Issue (Bug type)
4. Get Issue by ID
5. List Issues
6. Change Issue Status (Kanban workflow)
7. Assign Issue to User
8. Multi-Tenant Isolation (CRITICAL security test)

Bug Fix: Multi-Tenant Data Leakage
- Issue: IssueRepository did not filter by TenantId, allowing cross-tenant data access
- Solution: Implemented TenantContext service and added TenantId filtering to all repository queries
- Security Impact: CRITICAL - prevents unauthorized access to other tenants' issues

Changes:
- Added ColaFlow.Modules.IssueManagement.IntegrationTests project
- Added IssueManagementWebApplicationFactory for test infrastructure
- Added TestAuthHelper for JWT token generation in tests
- Added 8 comprehensive integration tests
- Added ITenantContext and TenantContext services for tenant isolation
- Updated IssueRepository to filter all queries by current tenant ID
- Registered TenantContext in module DI configuration

Test Status: 7/8 passed initially, 8/8 expected after multi-tenant fix
Test Framework: xUnit + FluentAssertions + WebApplicationFactory
Database: In-Memory (for fast, isolated tests)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-04 13:47:00 +01:00
parent 01e1263c12
commit 810fbeb1a0
8 changed files with 514 additions and 7 deletions

View File

@@ -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<ColaFlow.Modules.IssueManagement.Infrastructure.Services.ITenantContext,
ColaFlow.Modules.IssueManagement.Infrastructure.Services.TenantContext>();
// Register repositories
services.AddScoped<ColaFlow.Modules.IssueManagement.Domain.Repositories.IIssueRepository, IssueRepository>();
services.AddScoped<ColaFlow.Modules.IssueManagement.Domain.Repositories.IUnitOfWork, ColaFlow.Modules.IssueManagement.Infrastructure.Persistence.Repositories.UnitOfWork>();

View File

@@ -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;
/// <summary>
/// Repository implementation for Issue aggregate
/// Repository implementation for Issue aggregate with multi-tenant isolation
/// </summary>
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<Issue?> 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<List<Issue>> 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<bool> 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);
}
}

View File

@@ -0,0 +1,9 @@
namespace ColaFlow.Modules.IssueManagement.Infrastructure.Services;
/// <summary>
/// Provides access to the current tenant context
/// </summary>
public interface ITenantContext
{
Guid GetCurrentTenantId();
}

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace ColaFlow.Modules.IssueManagement.Infrastructure.Services;
/// <summary>
/// Implementation of ITenantContext that retrieves tenant ID from JWT claims
/// </summary>
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;
}
}

View File

@@ -0,0 +1,49 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<!-- Web Application Factory for Integration Testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<!-- Database Providers -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<!-- Assertion Library -->
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<!-- JWT Token Handling -->
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Reference API Project -->
<ProjectReference Include="..\..\..\..\src\ColaFlow.API\ColaFlow.API.csproj" />
<!-- Reference IssueManagement Module -->
<ProjectReference Include="..\..\..\..\src\Modules\IssueManagement\ColaFlow.Modules.IssueManagement.Application\ColaFlow.Modules.IssueManagement.Application.csproj" />
<ProjectReference Include="..\..\..\..\src\Modules\IssueManagement\ColaFlow.Modules.IssueManagement.Infrastructure\ColaFlow.Modules.IssueManagement.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\..\src\Modules\IssueManagement\ColaFlow.Modules.IssueManagement.Domain\ColaFlow.Modules.IssueManagement.Domain.csproj" />
<!-- Reference Identity Module (for auth) -->
<ProjectReference Include="..\..\..\..\src\Modules\Identity\ColaFlow.Modules.Identity.Application\ColaFlow.Modules.Identity.Application.csproj" />
<ProjectReference Include="..\..\..\..\src\Modules\Identity\ColaFlow.Modules.Identity.Infrastructure\ColaFlow.Modules.Identity.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\..\src\Modules\Identity\ColaFlow.Modules.Identity.Domain\ColaFlow.Modules.Identity.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// Custom WebApplicationFactory for Issue Management Integration Tests
/// Supports In-Memory database for fast, isolated tests
/// </summary>
public class IssueManagementWebApplicationFactory : WebApplicationFactory<Program>
{
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<string, string?>
{
["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<IdentityDbContext>(options =>
{
options.UseInMemoryDatabase(_testDatabaseName);
options.EnableSensitiveDataLogging();
});
services.AddDbContext<PMDbContext>(options =>
{
options.UseInMemoryDatabase(_testDatabaseName);
options.EnableSensitiveDataLogging();
});
services.AddDbContext<IssueManagementDbContext>(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<IdentityDbContext>();
identityDb.Database.EnsureCreated();
// Initialize ProjectManagement database
var pmDb = services.GetRequiredService<PMDbContext>();
pmDb.Database.EnsureCreated();
// Initialize IssueManagement database
var imDb = services.GetRequiredService<IssueManagementDbContext>();
imDb.Database.EnsureCreated();
}
catch (Exception ex)
{
Console.WriteLine($"Error initializing test database: {ex.Message}");
throw;
}
return host;
}
}

View File

@@ -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;
/// <summary>
/// Helper class for generating JWT tokens in tests
/// </summary>
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);
}
}

View File

@@ -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;
/// <summary>
/// Integration tests for Issue Management Module
/// Tests all CRUD operations, status changes, assignments, and multi-tenant isolation
/// </summary>
public class IssueManagementIntegrationTests : IClassFixture<IssueManagementWebApplicationFactory>
{
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<IssueDto>();
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<IssueDto>();
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<IssueDto>();
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<IssueDto>();
// 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<IssueDto>();
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<List<IssueDto>>();
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<IssueDto>();
// 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<IssueDto>();
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<IssueDto>();
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<IssueDto>();
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<IssueDto>();
// 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);
}
}