using System.Net; using System.Net.Http.Json; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using ColaFlow.Modules.ProjectManagement.IntegrationTests.Infrastructure; using ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; using ColaFlow.Modules.ProjectManagement.Application.DTOs; using ColaFlow.Modules.ProjectManagement.Application.Queries.AuditLogs; using Microsoft.EntityFrameworkCore; namespace ColaFlow.Modules.ProjectManagement.IntegrationTests; /// /// Integration tests for Audit Log Query API (Sprint 2 Story 2 Task 5) /// Tests the REST API endpoints for querying audit history /// public class AuditLogQueryApiTests : IClassFixture { private readonly PMWebApplicationFactory _factory; private readonly HttpClient _client; private readonly Guid _tenantId = Guid.NewGuid(); private readonly Guid _userId = Guid.NewGuid(); public AuditLogQueryApiTests(PMWebApplicationFactory factory) { _factory = factory; _client = _factory.CreateClient(); var token = TestAuthHelper.GenerateJwtToken(_userId, _tenantId, "test-tenant", "user@test.com"); _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); } [Fact] public async Task GetAuditLogById_ShouldReturnAuditLog() { // Arrange: Create a project (which generates an audit log) var projectResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "Test Project", Key = "TPRO", Description = "Test" }); var project = await projectResponse.Content.ReadFromJsonAsync(); // Get the audit log ID directly from database using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var auditLog = await context.AuditLogs .IgnoreQueryFilters() .Where(a => a.EntityType == "Project" && a.EntityId == project!.Id && a.Action == "Create") .FirstOrDefaultAsync(); // Act: Get audit log by ID via API var response = await _client.GetAsync($"/api/v1/auditlogs/{auditLog!.Id}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); result!.Id.Should().Be(auditLog.Id); result.EntityType.Should().Be("Project"); result.EntityId.Should().Be(project!.Id); result.Action.Should().Be("Create"); result.UserId.Should().Be(_userId); result.NewValues.Should().NotBeNullOrEmpty(); } [Fact] public async Task GetAuditLogById_NonExistent_ShouldReturn404() { // Act var response = await _client.GetAsync($"/api/v1/auditlogs/{Guid.NewGuid()}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task GetAuditLogsByEntity_ShouldReturnEntityHistory() { // Arrange: Create and update a project var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "Original Name", Key = "ORIG", Description = "Original description" }); var project = await createResponse.Content.ReadFromJsonAsync(); var projectId = project!.Id; await _client.PutAsJsonAsync($"/api/v1/projects/{projectId}", new { Name = "Updated Name", Description = "Updated description" }); // Act: Get audit history for the project var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{projectId}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); auditLogs.Should().HaveCount(2); // Create + Update auditLogs.Should().Contain(a => a.Action == "Create"); auditLogs.Should().Contain(a => a.Action == "Update"); auditLogs.Should().AllSatisfy(a => { a.EntityType.Should().Be("Project"); a.EntityId.Should().Be(projectId); a.UserId.Should().Be(_userId); }); } [Fact] public async Task GetAuditLogsByEntity_ShouldOnlyReturnChangedFields() { // Arrange: Create a project var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "Original Name", Key = "ORIG2", Description = "Original description" }); var project = await createResponse.Content.ReadFromJsonAsync(); var projectId = project!.Id; // Update only the Name field await _client.PutAsJsonAsync($"/api/v1/projects/{projectId}", new { Name = "Updated Name", Description = "Original description" // Same as before }); // Act: Get audit history var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{projectId}"); var auditLogs = await response.Content.ReadFromJsonAsync>(); // Assert: Update log should only contain changed fields var updateLog = auditLogs!.First(a => a.Action == "Update"); updateLog.OldValues.Should().NotBeNullOrEmpty(); updateLog.NewValues.Should().NotBeNullOrEmpty(); // NewValues should only contain "Name" field (not Description) updateLog.NewValues.Should().Contain("Name"); updateLog.NewValues.Should().Contain("Updated Name"); // Should NOT contain unchanged "Description" field (Phase 2 optimization) updateLog.NewValues.Should().NotContain("Original description"); } [Fact] public async Task GetAuditLogsByEntity_DifferentTenant_ShouldReturnEmpty() { // Arrange: Tenant 1 creates a project var tenant1Id = Guid.NewGuid(); var tenant1Token = TestAuthHelper.GenerateJwtToken(_userId, tenant1Id, "tenant1", "user@tenant1.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 = "T1PRO", Description = "Test" }); var project = await createResponse.Content.ReadFromJsonAsync(); var projectId = project!.Id; // Act: Tenant 2 tries to access the audit history var tenant2Id = Guid.NewGuid(); var tenant2Token = TestAuthHelper.GenerateJwtToken(_userId, tenant2Id, "tenant2", "user@tenant2.com"); _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{projectId}"); // Assert: Tenant 2 should not see Tenant 1's audit logs response.StatusCode.Should().Be(HttpStatusCode.OK); var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); auditLogs.Should().BeEmpty("Different tenant should not see other tenant's audit logs"); } [Fact] public async Task GetRecentAuditLogs_ShouldReturnRecentLogs() { // Arrange: Create multiple projects for (int i = 0; i < 5; i++) { await _client.PostAsJsonAsync("/api/v1/projects", new { Name = $"Project {i}", Key = $"P{i}", Description = "Test" }); } // Act: Get recent audit logs (default count = 100) var response = await _client.GetAsync("/api/v1/auditlogs/recent"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); auditLogs.Should().HaveCountGreaterOrEqualTo(5); // At least the 5 we just created auditLogs.Should().AllSatisfy(a => a.UserId.Should().Be(_userId)); } [Fact] public async Task GetRecentAuditLogs_WithCountLimit_ShouldRespectLimit() { // Arrange: Create multiple projects for (int i = 0; i < 10; i++) { await _client.PostAsJsonAsync("/api/v1/projects", new { Name = $"Project Limit {i}", Key = $"PL{i}", Description = "Test" }); } // Act: Get recent audit logs with limit of 5 var response = await _client.GetAsync("/api/v1/auditlogs/recent?count=5"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); auditLogs.Should().HaveCount(5, "API should respect the count limit"); } [Fact] public async Task GetRecentAuditLogs_ExceedMaxLimit_ShouldCapAt1000() { // Act: Request more than max allowed (1000) var response = await _client.GetAsync("/api/v1/auditlogs/recent?count=5000"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); auditLogs!.Count.Should().BeLessOrEqualTo(1000, "API should cap count at max 1000"); } [Fact] public async Task GetRecentAuditLogs_DifferentTenant_ShouldOnlyShowOwnLogs() { // Arrange: Tenant 1 creates a project var tenant1Id = Guid.NewGuid(); var tenant1Token = TestAuthHelper.GenerateJwtToken(_userId, 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 Recent Project", Key = "T1REC", Description = "Test" }); // Act: Tenant 2 gets recent logs var tenant2Id = Guid.NewGuid(); var tenant2Token = TestAuthHelper.GenerateJwtToken(_userId, tenant2Id, "tenant2", "user2@test.com"); _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tenant2Token); var response = await _client.GetAsync("/api/v1/auditlogs/recent"); // Assert: Tenant 2 should NOT see Tenant 1's audit logs var auditLogs = await response.Content.ReadFromJsonAsync>(); auditLogs.Should().NotBeNull(); // Verify no logs belong to tenant1 by checking none have tenant1's project using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var tenant1Logs = await context.AuditLogs .IgnoreQueryFilters() .Where(a => a.TenantId.Value == tenant1Id) .ToListAsync(); var tenant1LogIds = tenant1Logs.Select(a => a.Id).ToList(); auditLogs.Should().NotContain(a => tenant1LogIds.Contains(a.Id), "Tenant 2 should not see Tenant 1's audit logs"); } [Fact] public async Task AuditLog_ShouldCaptureUserId() { // Arrange: Create a project with specific user var specificUserId = Guid.NewGuid(); var token = TestAuthHelper.GenerateJwtToken(specificUserId, _tenantId, "test-tenant", "specific@test.com"); _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "User ID Test Project", Key = "UIDTP", Description = "Test user capture" }); var project = await createResponse.Content.ReadFromJsonAsync(); // Act: Get audit logs var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{project!.Id}"); var auditLogs = await response.Content.ReadFromJsonAsync>(); // Assert: UserId should match the specific user var createLog = auditLogs!.First(a => a.Action == "Create"); createLog.UserId.Should().Be(specificUserId, "Audit log should capture the user who performed the action"); } [Fact] public async Task AuditLog_CreateAction_ShouldHaveNewValuesOnly() { // Arrange & Act: Create a project var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "Create Test Project", Key = "CTP", Description = "Test create audit" }); var project = await createResponse.Content.ReadFromJsonAsync(); var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{project!.Id}"); var auditLogs = await response.Content.ReadFromJsonAsync>(); // Assert var createLog = auditLogs!.First(a => a.Action == "Create"); createLog.NewValues.Should().NotBeNullOrEmpty("Create action should have NewValues"); createLog.OldValues.Should().BeNullOrEmpty("Create action should NOT have OldValues"); } [Fact] public async Task AuditLog_DeleteAction_ShouldHaveOldValuesOnly() { // Arrange: Create and delete a project var createResponse = await _client.PostAsJsonAsync("/api/v1/projects", new { Name = "Delete Test Project", Key = "DTP", Description = "Test delete audit" }); var project = await createResponse.Content.ReadFromJsonAsync(); var projectId = project!.Id; await _client.DeleteAsync($"/api/v1/projects/{projectId}"); // Act var response = await _client.GetAsync($"/api/v1/auditlogs/entity/Project/{projectId}"); var auditLogs = await response.Content.ReadFromJsonAsync>(); // Assert var deleteLog = auditLogs!.First(a => a.Action == "Delete"); deleteLog.OldValues.Should().NotBeNullOrEmpty("Delete action should have OldValues"); deleteLog.NewValues.Should().BeNullOrEmpty("Delete action should NOT have NewValues"); } }