diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs new file mode 100644 index 0000000..4112486 --- /dev/null +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContextFactory.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; + +/// +/// Design-time DbContext factory for EF Core migrations +/// +public class PMDbContextFactory : IDesignTimeDbContextFactory +{ + public PMDbContext CreateDbContext(string[] args) + { + // Get connection string from environment or use default matching appsettings.Development.json + var connectionString = Environment.GetEnvironmentVariable("PMDatabase") + ?? "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password"; + + // Create DbContext options + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(connectionString, b => b.MigrationsAssembly("ColaFlow.Modules.ProjectManagement.Infrastructure")); + + // Create DbContext with a mock HttpContextAccessor (for migrations only) + return new PMDbContext(optionsBuilder.Options, new MockHttpContextAccessor()); + } + + /// + /// Mock HttpContextAccessor for design-time operations + /// Returns null HttpContext which PMDbContext handles gracefully + /// + private class MockHttpContextAccessor : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = null; + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj b/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj new file mode 100644 index 0000000..0450298 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/ColaFlow.API.Tests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs b/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs new file mode 100644 index 0000000..5b4a771 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Helpers/TestHelpers.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using System.Text.Json; + +namespace ColaFlow.API.Tests.Helpers; + +public static class TestHelpers +{ + /// + /// Get property value from an anonymous object using reflection + /// + public static T? GetPropertyValue(object obj, string propertyName) + { + var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + { + throw new ArgumentException($"Property '{propertyName}' not found on type '{obj.GetType().Name}'"); + } + + var value = property.GetValue(obj); + if (value is T typedValue) + { + return typedValue; + } + + if (value == null && default(T) == null) + { + return default; + } + + // Try conversion + try + { + return (T)Convert.ChangeType(value, typeof(T))!; + } + catch + { + throw new InvalidCastException($"Cannot convert property '{propertyName}' value '{value}' to type '{typeof(T).Name}'"); + } + } + + /// + /// Check if object has a property with a specific value + /// + public static bool HasPropertyWithValue(object obj, string propertyName, T expectedValue) + { + try + { + var actualValue = GetPropertyValue(obj, propertyName); + return EqualityComparer.Default.Equals(actualValue, expectedValue); + } + catch + { + return false; + } + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs new file mode 100644 index 0000000..089b5a0 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/BaseHubTests.cs @@ -0,0 +1,249 @@ +using System.Security.Claims; +using ColaFlow.API.Hubs; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; + +namespace ColaFlow.API.Tests.Hubs; + +public class BaseHubTests +{ + private readonly Mock _mockClients; + private readonly Mock _mockGroups; + private readonly Mock _mockContext; + private readonly TestHub _hub; + + public BaseHubTests() + { + _mockClients = new Mock(); + _mockGroups = new Mock(); + _mockContext = new Mock(); + + _hub = new TestHub + { + Clients = _mockClients.Object, + Groups = _mockGroups.Object, + Context = _mockContext.Object + }; + } + + [Fact] + public void GetCurrentUserId_WithValidSubClaim_ReturnsUserId() + { + // Arrange + var userId = Guid.NewGuid(); + var claims = new[] { new Claim("sub", userId.ToString()) }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act + var result = _hub.GetCurrentUserIdPublic(); + + // Assert + result.Should().Be(userId); + } + + [Fact] + public void GetCurrentUserId_WithValidUserIdClaim_ReturnsUserId() + { + // Arrange + var userId = Guid.NewGuid(); + var claims = new[] { new Claim("user_id", userId.ToString()) }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act + var result = _hub.GetCurrentUserIdPublic(); + + // Assert + result.Should().Be(userId); + } + + [Fact] + public void GetCurrentUserId_WithMissingClaim_ThrowsUnauthorizedException() + { + // Arrange + var claims = new[] { new Claim("some_other_claim", "value") }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act & Assert + var act = () => _hub.GetCurrentUserIdPublic(); + act.Should().Throw() + .WithMessage("User ID not found in token"); + } + + [Fact] + public void GetCurrentUserId_WithInvalidGuid_ThrowsUnauthorizedException() + { + // Arrange + var claims = new[] { new Claim("sub", "not-a-guid") }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act & Assert + var act = () => _hub.GetCurrentUserIdPublic(); + act.Should().Throw() + .WithMessage("User ID not found in token"); + } + + [Fact] + public void GetCurrentUserId_WithNullUser_ThrowsUnauthorizedException() + { + // Arrange + _mockContext.Setup(c => c.User).Returns((ClaimsPrincipal)null!); + + // Act & Assert + var act = () => _hub.GetCurrentUserIdPublic(); + act.Should().Throw() + .WithMessage("User ID not found in token"); + } + + [Fact] + public void GetCurrentTenantId_WithValidClaim_ReturnsTenantId() + { + // Arrange + var tenantId = Guid.NewGuid(); + var claims = new[] { new Claim("tenant_id", tenantId.ToString()) }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act + var result = _hub.GetCurrentTenantIdPublic(); + + // Assert + result.Should().Be(tenantId); + } + + [Fact] + public void GetCurrentTenantId_WithMissingClaim_ThrowsUnauthorizedException() + { + // Arrange + var claims = new[] { new Claim("some_other_claim", "value") }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act & Assert + var act = () => _hub.GetCurrentTenantIdPublic(); + act.Should().Throw() + .WithMessage("Tenant ID not found in token"); + } + + [Fact] + public void GetCurrentTenantId_WithInvalidGuid_ThrowsUnauthorizedException() + { + // Arrange + var claims = new[] { new Claim("tenant_id", "not-a-guid") }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act & Assert + var act = () => _hub.GetCurrentTenantIdPublic(); + act.Should().Throw() + .WithMessage("Tenant ID not found in token"); + } + + [Fact] + public void GetTenantGroupName_ReturnsCorrectFormat() + { + // Arrange + var tenantId = Guid.NewGuid(); + + // Act + var result = _hub.GetTenantGroupNamePublic(tenantId); + + // Assert + result.Should().Be($"tenant-{tenantId}"); + } + + [Fact] + public async Task OnConnectedAsync_WithValidClaims_AutoJoinsTenantGroup() + { + // Arrange + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var connectionId = "test-connection-id"; + + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()) + }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + _mockContext.Setup(c => c.ConnectionId).Returns(connectionId); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockGroups.Verify(g => g.AddToGroupAsync( + connectionId, + $"tenant-{tenantId}", + default), Times.Once); + } + + [Fact] + public async Task OnConnectedAsync_WithMissingTenantId_AbortsConnection() + { + // Arrange + var userId = Guid.NewGuid(); + var claims = new[] { new Claim("sub", userId.ToString()) }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + _mockContext.Setup(c => c.ConnectionId).Returns("test-connection-id"); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockContext.Verify(c => c.Abort(), Times.Once); + _mockGroups.Verify(g => g.AddToGroupAsync( + It.IsAny(), + It.IsAny(), + default), Times.Never); + } + + [Fact] + public async Task OnConnectedAsync_WithMissingUserId_AbortsConnection() + { + // Arrange + var tenantId = Guid.NewGuid(); + var claims = new[] { new Claim("tenant_id", tenantId.ToString()) }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + _mockContext.Setup(c => c.ConnectionId).Returns("test-connection-id"); + + // Act + await _hub.OnConnectedAsync(); + + // Assert + _mockContext.Verify(c => c.Abort(), Times.Once); + _mockGroups.Verify(g => g.AddToGroupAsync( + It.IsAny(), + It.IsAny(), + default), Times.Never); + } + + [Fact] + public async Task OnDisconnectedAsync_WithValidClaims_CompletesSuccessfully() + { + // Arrange + var userId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()) + }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + // Act + var act = async () => await _hub.OnDisconnectedAsync(null); + + // Assert + await act.Should().NotThrowAsync(); + } + + // Test Hub implementation to expose protected methods + private class TestHub : BaseHub + { + public Guid GetCurrentUserIdPublic() => GetCurrentUserId(); + public Guid GetCurrentTenantIdPublic() => GetCurrentTenantId(); + public string GetTenantGroupNamePublic(Guid tenantId) => GetTenantGroupName(tenantId); + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs new file mode 100644 index 0000000..e6d9861 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/NotificationHubTests.cs @@ -0,0 +1,189 @@ +using System.Security.Claims; +using ColaFlow.API.Hubs; +using ColaFlow.API.Tests.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; + +namespace ColaFlow.API.Tests.Hubs; + +public class NotificationHubTests +{ + private readonly Mock _mockClients; + private readonly Mock _mockContext; + private readonly Mock _mockCallerProxy; + private readonly NotificationHub _hub; + private readonly Guid _userId; + private readonly Guid _tenantId; + + public NotificationHubTests() + { + _mockClients = new Mock(); + _mockContext = new Mock(); + _mockCallerProxy = new Mock(); + + _userId = Guid.NewGuid(); + _tenantId = Guid.NewGuid(); + + // Setup context with valid claims + var claims = new[] + { + new Claim("sub", _userId.ToString()), + new Claim("tenant_id", _tenantId.ToString()) + }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + _mockContext.Setup(c => c.ConnectionId).Returns("test-connection-id"); + + // Setup clients mock + _mockClients.Setup(c => c.Caller).Returns(_mockCallerProxy.Object); + + _hub = new NotificationHub + { + Clients = _mockClients.Object, + Context = _mockContext.Object + }; + } + + [Fact] + public async Task MarkAsRead_WithValidNotificationId_SendsNotificationReadToCaller() + { + // Arrange + var notificationId = Guid.NewGuid(); + + // Act + await _hub.MarkAsRead(notificationId); + + // Assert + _mockClients.Verify(c => c.Caller, Times.Once); + _mockCallerProxy.Verify(p => p.SendCoreAsync( + "NotificationRead", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task MarkAsRead_ContainsCorrectNotificationId() + { + // Arrange + var notificationId = Guid.NewGuid(); + object? capturedData = null; + + _mockCallerProxy + .Setup(p => p.SendCoreAsync("NotificationRead", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.MarkAsRead(notificationId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "NotificationId").Should().Be(notificationId); + } + + [Fact] + public async Task MarkAsRead_ContainsReadAtTimestamp() + { + // Arrange + var notificationId = Guid.NewGuid(); + object? capturedData = null; + + _mockCallerProxy + .Setup(p => p.SendCoreAsync("NotificationRead", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.MarkAsRead(notificationId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "ReadAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task MarkAsRead_MultipleNotifications_SendsMultipleEvents() + { + // Arrange + var notificationId1 = Guid.NewGuid(); + var notificationId2 = Guid.NewGuid(); + var notificationId3 = Guid.NewGuid(); + + // Act + await _hub.MarkAsRead(notificationId1); + await _hub.MarkAsRead(notificationId2); + await _hub.MarkAsRead(notificationId3); + + // Assert + _mockCallerProxy.Verify(p => p.SendCoreAsync( + "NotificationRead", + It.IsAny(), + default), Times.Exactly(3)); + } + + [Fact] + public async Task MarkAsRead_OnlySendsToCaller_NotOtherClients() + { + // Arrange + var notificationId = Guid.NewGuid(); + var mockOthersProxy = new Mock(); + _mockClients.Setup(c => c.Others).Returns(mockOthersProxy.Object); + + // Act + await _hub.MarkAsRead(notificationId); + + // Assert + _mockCallerProxy.Verify(p => p.SendCoreAsync( + "NotificationRead", + It.IsAny(), + default), Times.Once); + mockOthersProxy.Verify(p => p.SendCoreAsync( + It.IsAny(), + It.IsAny(), + default), Times.Never); + } + + [Fact] + public void MarkAsRead_WithMissingUserId_ThrowsUnauthorizedException() + { + // Arrange + var notificationId = Guid.NewGuid(); + var claims = new[] { new Claim("tenant_id", _tenantId.ToString()) }; + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hubWithoutUserId = new NotificationHub + { + Clients = _mockClients.Object, + Context = _mockContext.Object + }; + + // Act & Assert + var act = async () => await hubWithoutUserId.MarkAsRead(notificationId); + act.Should().ThrowAsync() + .WithMessage("User ID not found in token"); + } + + [Fact] + public async Task MarkAsRead_EmptyGuid_StillProcesses() + { + // Arrange + var emptyGuid = Guid.Empty; + + // Act + var act = async () => await _hub.MarkAsRead(emptyGuid); + + // Assert + await act.Should().NotThrowAsync(); + _mockCallerProxy.Verify(p => p.SendCoreAsync( + "NotificationRead", + It.Is(args => args.Length == 1), + default), Times.Once); + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Hubs/ProjectHubTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/ProjectHubTests.cs new file mode 100644 index 0000000..049eb31 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Hubs/ProjectHubTests.cs @@ -0,0 +1,383 @@ +using System.Security.Claims; +using ColaFlow.API.Hubs; +using ColaFlow.API.Tests.Helpers; +using ColaFlow.Modules.ProjectManagement.Application.Services; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; + +namespace ColaFlow.API.Tests.Hubs; + +public class ProjectHubTests +{ + private readonly Mock _mockClients; + private readonly Mock _mockGroups; + private readonly Mock _mockContext; + private readonly Mock _mockClientProxy; + private readonly Mock _mockPermissionService; + private readonly ProjectHub _hub; + private readonly Guid _userId; + private readonly Guid _tenantId; + private const string ConnectionId = "test-connection-id"; + + public ProjectHubTests() + { + _mockClients = new Mock(); + _mockGroups = new Mock(); + _mockContext = new Mock(); + _mockClientProxy = new Mock(); + _mockPermissionService = new Mock(); + + _userId = Guid.NewGuid(); + _tenantId = Guid.NewGuid(); + + // Setup context with valid claims + var claims = new[] + { + new Claim("sub", _userId.ToString()), + new Claim("tenant_id", _tenantId.ToString()) + }; + + _mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + _mockContext.Setup(c => c.ConnectionId).Returns(ConnectionId); + _mockContext.Setup(c => c.ConnectionAborted).Returns(CancellationToken.None); + + // Setup clients mock + _mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(_mockClientProxy.Object); + + // Default: allow all permissions (individual tests can override) + _mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + _hub = new ProjectHub(_mockPermissionService.Object) + { + Clients = _mockClients.Object, + Groups = _mockGroups.Object, + Context = _mockContext.Object + }; + } + + [Fact] + public async Task JoinProject_WithValidProjectId_AddsToGroup() + { + // Arrange + var projectId = Guid.NewGuid(); + + // Act + await _hub.JoinProject(projectId); + + // Assert + _mockGroups.Verify(g => g.AddToGroupAsync( + ConnectionId, + $"project-{projectId}", + default), Times.Once); + } + + [Fact] + public async Task JoinProject_WithValidProjectId_BroadcastsUserJoined() + { + // Arrange + var projectId = Guid.NewGuid(); + + // Act + await _hub.JoinProject(projectId); + + // Assert + _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "UserJoinedProject", + It.Is(args => + args.Length == 1 && + args[0] != null), + default), Times.Once); + } + + [Fact] + public async Task JoinProject_UserJoinedEvent_ContainsCorrectData() + { + // Arrange + var projectId = Guid.NewGuid(); + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("UserJoinedProject", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.JoinProject(projectId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); + TestHelpers.GetPropertyValue(capturedData!, "ProjectId").Should().Be(projectId); + TestHelpers.GetPropertyValue(capturedData!, "JoinedAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task JoinProject_MultipleProjects_AddsToMultipleGroups() + { + // Arrange + var projectId1 = Guid.NewGuid(); + var projectId2 = Guid.NewGuid(); + + // Act + await _hub.JoinProject(projectId1); + await _hub.JoinProject(projectId2); + + // Assert + _mockGroups.Verify(g => g.AddToGroupAsync( + ConnectionId, + $"project-{projectId1}", + default), Times.Once); + _mockGroups.Verify(g => g.AddToGroupAsync( + ConnectionId, + $"project-{projectId2}", + default), Times.Once); + } + + [Fact] + public async Task LeaveProject_WithValidProjectId_RemovesFromGroup() + { + // Arrange + var projectId = Guid.NewGuid(); + + // Act + await _hub.LeaveProject(projectId); + + // Assert + _mockGroups.Verify(g => g.RemoveFromGroupAsync( + ConnectionId, + $"project-{projectId}", + default), Times.Once); + } + + [Fact] + public async Task LeaveProject_WithValidProjectId_BroadcastsUserLeft() + { + // Arrange + var projectId = Guid.NewGuid(); + + // Act + await _hub.LeaveProject(projectId); + + // Assert + _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "UserLeftProject", + It.Is(args => + args.Length == 1 && + args[0] != null), + default), Times.Once); + } + + [Fact] + public async Task LeaveProject_UserLeftEvent_ContainsCorrectData() + { + // Arrange + var projectId = Guid.NewGuid(); + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("UserLeftProject", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.LeaveProject(projectId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); + TestHelpers.GetPropertyValue(capturedData!, "ProjectId").Should().Be(projectId); + TestHelpers.GetPropertyValue(capturedData!, "LeftAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task SendTypingIndicator_WithIsTypingTrue_BroadcastsToGroup() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + + // Act + await _hub.SendTypingIndicator(projectId, issueId, isTyping: true); + + // Assert + _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "TypingIndicator", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task SendTypingIndicator_WithIsTypingFalse_BroadcastsToGroup() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + + // Act + await _hub.SendTypingIndicator(projectId, issueId, isTyping: false); + + // Assert + _mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "TypingIndicator", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task SendTypingIndicator_ContainsCorrectData() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + var isTyping = true; + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.SendTypingIndicator(projectId, issueId, isTyping); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "UserId").Should().Be(_userId); + TestHelpers.GetPropertyValue(capturedData!, "IssueId").Should().Be(issueId); + TestHelpers.GetPropertyValue(capturedData!, "IsTyping").Should().Be(isTyping); + } + + [Fact] + public async Task SendTypingIndicator_WithDifferentIssues_BroadcastsSeparately() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId1 = Guid.NewGuid(); + var issueId2 = Guid.NewGuid(); + + // Act + await _hub.SendTypingIndicator(projectId, issueId1, isTyping: true); + await _hub.SendTypingIndicator(projectId, issueId2, isTyping: true); + + // Assert + _mockClientProxy.Verify(p => p.SendCoreAsync( + "TypingIndicator", + It.IsAny(), + default), Times.Exactly(2)); + } + + [Fact] + public async Task SendTypingIndicator_ToggleTyping_SendsBothStates() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + var capturedStates = new List(); + + _mockClientProxy + .Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default)) + .Callback((method, args, ct) => + { + var isTyping = TestHelpers.GetPropertyValue(args[0], "IsTyping"); + capturedStates.Add(isTyping); + }) + .Returns(Task.CompletedTask); + + // Act + await _hub.SendTypingIndicator(projectId, issueId, isTyping: true); + await _hub.SendTypingIndicator(projectId, issueId, isTyping: false); + + // Assert + capturedStates.Should().HaveCount(2); + capturedStates[0].Should().BeTrue(); + capturedStates[1].Should().BeFalse(); + } + + [Fact] + public async Task JoinProject_WithoutPermission_ThrowsHubException() + { + // Arrange + var projectId = Guid.NewGuid(); + _mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) + .ReturnsAsync(false); + + // Act & Assert + var act = async () => await _hub.JoinProject(projectId); + await act.Should().ThrowAsync() + .WithMessage("You do not have permission to access this project"); + } + + [Fact] + public async Task JoinProject_WithoutPermission_DoesNotAddToGroup() + { + // Arrange + var projectId = Guid.NewGuid(); + _mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) + .ReturnsAsync(false); + + // Act + try + { + await _hub.JoinProject(projectId); + } + catch (HubException) + { + // Expected exception + } + + // Assert + _mockGroups.Verify(g => g.AddToGroupAsync( + It.IsAny(), + It.IsAny(), + default), Times.Never); + } + + [Fact] + public async Task LeaveProject_WithoutPermission_ThrowsHubException() + { + // Arrange + var projectId = Guid.NewGuid(); + _mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(_userId, projectId, It.IsAny())) + .ReturnsAsync(false); + + // Act & Assert + var act = async () => await _hub.LeaveProject(projectId); + await act.Should().ThrowAsync() + .WithMessage("You do not have permission to access this project"); + } + + [Fact] + public async Task JoinProject_VerifiesPermissionWithCorrectUserId() + { + // Arrange + var projectId = Guid.NewGuid(); + + // Act + await _hub.JoinProject(projectId); + + // Assert + _mockPermissionService.Verify(s => s.IsUserProjectMemberAsync( + _userId, + projectId, + It.IsAny()), Times.Once); + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Services/ProjectNotificationServiceAdapterTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Services/ProjectNotificationServiceAdapterTests.cs new file mode 100644 index 0000000..fb7eea6 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Services/ProjectNotificationServiceAdapterTests.cs @@ -0,0 +1,141 @@ +using ColaFlow.API.Services; +using FluentAssertions; +using Moq; + +namespace ColaFlow.API.Tests.Services; + +public class ProjectNotificationServiceAdapterTests +{ + private readonly Mock _mockRealtimeService; + private readonly ProjectNotificationServiceAdapter _adapter; + + public ProjectNotificationServiceAdapterTests() + { + _mockRealtimeService = new Mock(); + _adapter = new ProjectNotificationServiceAdapter(_mockRealtimeService.Object); + } + + [Fact] + public async Task NotifyProjectCreated_CallsRealtimeService() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var project = new { Id = projectId, Name = "Test Project" }; + + // Act + await _adapter.NotifyProjectCreated(tenantId, projectId, project); + + // Assert + _mockRealtimeService.Verify(s => s.NotifyProjectCreated( + tenantId, + projectId, + project), Times.Once); + } + + [Fact] + public async Task NotifyProjectUpdated_CallsRealtimeService() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var project = new { Id = projectId, Name = "Updated Project" }; + + // Act + await _adapter.NotifyProjectUpdated(tenantId, projectId, project); + + // Assert + _mockRealtimeService.Verify(s => s.NotifyProjectUpdated( + tenantId, + projectId, + project), Times.Once); + } + + [Fact] + public async Task NotifyProjectArchived_CallsRealtimeService() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + // Act + await _adapter.NotifyProjectArchived(tenantId, projectId); + + // Assert + _mockRealtimeService.Verify(s => s.NotifyProjectArchived( + tenantId, + projectId), Times.Once); + } + + [Fact] + public async Task Adapter_MultipleOperations_AllDelegatedCorrectly() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId1 = Guid.NewGuid(); + var projectId2 = Guid.NewGuid(); + var project1 = new { Id = projectId1, Name = "Project 1" }; + var project2 = new { Id = projectId2, Name = "Project 2" }; + + // Act + await _adapter.NotifyProjectCreated(tenantId, projectId1, project1); + await _adapter.NotifyProjectUpdated(tenantId, projectId2, project2); + await _adapter.NotifyProjectArchived(tenantId, projectId1); + + // Assert + _mockRealtimeService.Verify(s => s.NotifyProjectCreated( + tenantId, projectId1, project1), Times.Once); + _mockRealtimeService.Verify(s => s.NotifyProjectUpdated( + tenantId, projectId2, project2), Times.Once); + _mockRealtimeService.Verify(s => s.NotifyProjectArchived( + tenantId, projectId1), Times.Once); + } + + [Fact] + public async Task NotifyProjectCreated_WithNullProject_StillCallsRealtimeService() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + // Act + await _adapter.NotifyProjectCreated(tenantId, projectId, null!); + + // Assert + _mockRealtimeService.Verify(s => s.NotifyProjectCreated( + tenantId, + projectId, + null!), Times.Once); + } + + [Fact] + public async Task Adapter_PreservesParameterValues() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var project = new { Id = projectId, Name = "Test", Status = "Active", CreatedBy = "User1" }; + + Guid capturedTenantId = Guid.Empty; + Guid capturedProjectId = Guid.Empty; + object? capturedProject = null; + + _mockRealtimeService + .Setup(s => s.NotifyProjectCreated(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((tid, pid, proj) => + { + capturedTenantId = tid; + capturedProjectId = pid; + capturedProject = proj; + }) + .Returns(Task.CompletedTask); + + // Act + await _adapter.NotifyProjectCreated(tenantId, projectId, project); + + // Assert + capturedTenantId.Should().Be(tenantId); + capturedProjectId.Should().Be(projectId); + capturedProject.Should().BeSameAs(project); + } +} diff --git a/colaflow-api/tests/ColaFlow.API.Tests/Services/RealtimeNotificationServiceTests.cs b/colaflow-api/tests/ColaFlow.API.Tests/Services/RealtimeNotificationServiceTests.cs new file mode 100644 index 0000000..d2b0974 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.API.Tests/Services/RealtimeNotificationServiceTests.cs @@ -0,0 +1,409 @@ +using ColaFlow.API.Hubs; +using ColaFlow.API.Services; +using ColaFlow.API.Tests.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ColaFlow.API.Tests.Services; + +public class RealtimeNotificationServiceTests +{ + private readonly Mock> _mockProjectHubContext; + private readonly Mock> _mockNotificationHubContext; + private readonly Mock> _mockLogger; + private readonly Mock _mockProjectClients; + private readonly Mock _mockNotificationClients; + private readonly Mock _mockClientProxy; + private readonly RealtimeNotificationService _service; + + public RealtimeNotificationServiceTests() + { + _mockProjectHubContext = new Mock>(); + _mockNotificationHubContext = new Mock>(); + _mockLogger = new Mock>(); + _mockProjectClients = new Mock(); + _mockNotificationClients = new Mock(); + _mockClientProxy = new Mock(); + + _mockProjectHubContext.Setup(h => h.Clients).Returns(_mockProjectClients.Object); + _mockNotificationHubContext.Setup(h => h.Clients).Returns(_mockNotificationClients.Object); + _mockProjectClients.Setup(c => c.Group(It.IsAny())).Returns(_mockClientProxy.Object); + _mockNotificationClients.Setup(c => c.Group(It.IsAny())).Returns(_mockClientProxy.Object); + _mockNotificationClients.Setup(c => c.User(It.IsAny())).Returns(_mockClientProxy.Object); + + _service = new RealtimeNotificationService( + _mockProjectHubContext.Object, + _mockNotificationHubContext.Object, + _mockLogger.Object); + } + + [Fact] + public async Task NotifyProjectCreated_SendsToTenantGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var project = new { Id = projectId, Name = "Test Project" }; + + // Act + await _service.NotifyProjectCreated(tenantId, projectId, project); + + // Assert + _mockProjectClients.Verify(c => c.Group($"tenant-{tenantId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "ProjectCreated", + It.Is(args => args.Length == 1 && args[0] == project), + default), Times.Once); + } + + [Fact] + public async Task NotifyProjectUpdated_SendsToBothProjectAndTenantGroups() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var project = new { Id = projectId, Name = "Updated Project" }; + + // Act + await _service.NotifyProjectUpdated(tenantId, projectId, project); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockProjectClients.Verify(c => c.Group($"tenant-{tenantId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "ProjectUpdated", + It.Is(args => args.Length == 1 && args[0] == project), + default), Times.Exactly(2)); + } + + [Fact] + public async Task NotifyProjectArchived_SendsToBothGroups() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + // Act + await _service.NotifyProjectArchived(tenantId, projectId); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockProjectClients.Verify(c => c.Group($"tenant-{tenantId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "ProjectArchived", + It.Is(args => args.Length == 1), + default), Times.Exactly(2)); + } + + [Fact] + public async Task NotifyProjectArchived_ContainsCorrectProjectId() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("ProjectArchived", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyProjectArchived(tenantId, projectId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "ProjectId").Should().Be(projectId); + } + + [Fact] + public async Task NotifyProjectUpdate_SendsToProjectGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var updateData = new { Field = "Status", Value = "Active" }; + + // Act + await _service.NotifyProjectUpdate(tenantId, projectId, updateData); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "ProjectUpdated", + It.Is(args => args.Length == 1 && args[0] == updateData), + default), Times.Once); + } + + [Fact] + public async Task NotifyIssueCreated_SendsToProjectGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issue = new { Id = Guid.NewGuid(), Title = "New Issue" }; + + // Act + await _service.NotifyIssueCreated(tenantId, projectId, issue); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "IssueCreated", + It.Is(args => args.Length == 1 && args[0] == issue), + default), Times.Once); + } + + [Fact] + public async Task NotifyIssueUpdated_SendsToProjectGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issue = new { Id = Guid.NewGuid(), Title = "Updated Issue" }; + + // Act + await _service.NotifyIssueUpdated(tenantId, projectId, issue); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "IssueUpdated", + It.Is(args => args.Length == 1 && args[0] == issue), + default), Times.Once); + } + + [Fact] + public async Task NotifyIssueDeleted_SendsToProjectGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + + // Act + await _service.NotifyIssueDeleted(tenantId, projectId, issueId); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "IssueDeleted", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task NotifyIssueDeleted_ContainsCorrectIssueId() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("IssueDeleted", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyIssueDeleted(tenantId, projectId, issueId); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "IssueId").Should().Be(issueId); + } + + [Fact] + public async Task NotifyIssueStatusChanged_SendsWithCorrectData() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + var oldStatus = "To Do"; + var newStatus = "In Progress"; + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("IssueStatusChanged", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyIssueStatusChanged(tenantId, projectId, issueId, oldStatus, newStatus); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "IssueId").Should().Be(issueId); + TestHelpers.GetPropertyValue(capturedData!, "OldStatus").Should().Be(oldStatus); + TestHelpers.GetPropertyValue(capturedData!, "NewStatus").Should().Be(newStatus); + TestHelpers.GetPropertyValue(capturedData!, "ChangedAt").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task NotifyIssueStatusChanged_SendsToProjectGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + + // Act + await _service.NotifyIssueStatusChanged(tenantId, projectId, issueId, "To Do", "Done"); + + // Assert + _mockProjectClients.Verify(c => c.Group($"project-{projectId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "IssueStatusChanged", + It.IsAny(), + default), Times.Once); + } + + [Fact] + public async Task NotifyUser_SendsToSpecificUser() + { + // Arrange + var userId = Guid.NewGuid(); + var message = "Test notification"; + var type = "info"; + + // Act + await _service.NotifyUser(userId, message, type); + + // Assert + _mockNotificationClients.Verify(c => c.User(userId.ToString()), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "Notification", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task NotifyUser_ContainsCorrectMessageData() + { + // Arrange + var userId = Guid.NewGuid(); + var message = "Important update"; + var type = "warning"; + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("Notification", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyUser(userId, message, type); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "Message").Should().Be(message); + TestHelpers.GetPropertyValue(capturedData!, "Type").Should().Be(type); + TestHelpers.GetPropertyValue(capturedData!, "Timestamp").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task NotifyUser_WithDefaultType_UsesInfo() + { + // Arrange + var userId = Guid.NewGuid(); + var message = "Test"; + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("Notification", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyUser(userId, message); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "Type").Should().Be("info"); + } + + [Fact] + public async Task NotifyUsersInTenant_SendsToTenantGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var message = "Tenant announcement"; + var type = "info"; + + // Act + await _service.NotifyUsersInTenant(tenantId, message, type); + + // Assert + _mockNotificationClients.Verify(c => c.Group($"tenant-{tenantId}"), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync( + "Notification", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task NotifyUsersInTenant_ContainsCorrectData() + { + // Arrange + var tenantId = Guid.NewGuid(); + var message = "System maintenance scheduled"; + var type = "warning"; + object? capturedData = null; + + _mockClientProxy + .Setup(p => p.SendCoreAsync("Notification", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedData = args[0]; + }) + .Returns(Task.CompletedTask); + + // Act + await _service.NotifyUsersInTenant(tenantId, message, type); + + // Assert + capturedData.Should().NotBeNull(); + TestHelpers.GetPropertyValue(capturedData!, "Message").Should().Be(message); + TestHelpers.GetPropertyValue(capturedData!, "Type").Should().Be(type); + TestHelpers.GetPropertyValue(capturedData!, "Timestamp").Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task MultipleNotifications_AllSentCorrectly() + { + // Arrange + var tenantId = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + // Act + await _service.NotifyProjectCreated(tenantId, projectId, new { Name = "Project1" }); + await _service.NotifyUser(userId, "Hello"); + await _service.NotifyUsersInTenant(tenantId, "Announcement"); + + // Assert + _mockClientProxy.Verify(p => p.SendCoreAsync( + It.IsAny(), + It.IsAny(), + default), Times.Exactly(3)); + } +} diff --git a/colaflow-api/tests/ColaFlow.IntegrationTests/ColaFlow.IntegrationTests.csproj b/colaflow-api/tests/ColaFlow.IntegrationTests/ColaFlow.IntegrationTests.csproj index f81b655..249a3f6 100644 --- a/colaflow-api/tests/ColaFlow.IntegrationTests/ColaFlow.IntegrationTests.csproj +++ b/colaflow-api/tests/ColaFlow.IntegrationTests/ColaFlow.IntegrationTests.csproj @@ -9,7 +9,9 @@ + + diff --git a/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRCollaborationTests.cs b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRCollaborationTests.cs new file mode 100644 index 0000000..bf76a91 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRCollaborationTests.cs @@ -0,0 +1,346 @@ +using System.Security.Claims; +using ColaFlow.API.Hubs; +using ColaFlow.Modules.ProjectManagement.Application.Services; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; +using Xunit; + +namespace ColaFlow.IntegrationTests.SignalR; + +/// +/// Tests for SignalR multi-user collaboration scenarios +/// +public class SignalRCollaborationTests +{ + [Fact] + public async Task MultipleUsers_JoinSameProject_AllReceiveTypingIndicators() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + + var user1Id = Guid.NewGuid(); + var user2Id = Guid.NewGuid(); + var user3Id = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Create hub for user 1 + var hub1 = CreateProjectHub(user1Id, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act - User 1 joins project + await hub1.JoinProject(projectId); + + // User 1 sends typing indicator + await hub1.SendTypingIndicator(projectId, issueId, isTyping: true); + + // Assert - Others in group receive typing indicator + mockClients.Verify(c => c.OthersInGroup($"project-{projectId}"), Times.Once); + mockClientProxy.Verify(p => p.SendCoreAsync( + "TypingIndicator", + It.Is(args => args.Length == 1), + default), Times.Once); + } + + [Fact] + public async Task TwoUsers_DifferentProjects_DoNotReceiveEachOthersMessages() + { + // Arrange + var project1 = Guid.NewGuid(); + var project2 = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var user1Id = Guid.NewGuid(); + var user2Id = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var hub1 = CreateProjectHub(user1Id, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + var hub2 = CreateProjectHub(user2Id, tenantId, "conn-2", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act - Users join different projects + await hub1.JoinProject(project1); + await hub2.JoinProject(project2); + + // User 1 sends typing indicator to project 1 + await hub1.SendTypingIndicator(project1, Guid.NewGuid(), isTyping: true); + + // Assert - Message sent to project 1 group only + mockClients.Verify(c => c.OthersInGroup($"project-{project1}"), Times.Once); + mockClients.Verify(c => c.OthersInGroup($"project-{project2}"), Times.Never); + } + + [Fact] + public async Task User_JoinThenLeaveProject_ReceivesThenStopsReceiving() + { + // Arrange + var projectId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny())) + .ReturnsAsync(true); + + var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act + await hub.JoinProject(projectId); + await hub.LeaveProject(projectId); + + // Assert + mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"project-{projectId}", default), Times.Once); + mockGroups.Verify(g => g.RemoveFromGroupAsync("conn-1", $"project-{projectId}", default), Times.Once); + } + + [Fact] + public async Task User_JoinProject_OthersNotifiedOfJoin() + { + // Arrange + var projectId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + object? capturedJoinEvent = null; + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockClientProxy + .Setup(p => p.SendCoreAsync("UserJoinedProject", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedJoinEvent = args[0]; + }) + .Returns(Task.CompletedTask); + + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny())) + .ReturnsAsync(true); + + var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act + await hub.JoinProject(projectId); + + // Assert + capturedJoinEvent.Should().NotBeNull(); + var data = capturedJoinEvent as dynamic; + ((Guid)data!.UserId).Should().Be(userId); + ((Guid)data.ProjectId).Should().Be(projectId); + } + + [Fact] + public async Task User_LeaveProject_OthersNotifiedOfLeave() + { + // Arrange + var projectId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + object? capturedLeaveEvent = null; + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockClientProxy + .Setup(p => p.SendCoreAsync("UserLeftProject", It.IsAny(), default)) + .Callback((method, args, ct) => + { + capturedLeaveEvent = args[0]; + }) + .Returns(Task.CompletedTask); + + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(userId, projectId, It.IsAny())) + .ReturnsAsync(true); + + var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act + await hub.LeaveProject(projectId); + + // Assert + capturedLeaveEvent.Should().NotBeNull(); + var data = capturedLeaveEvent as dynamic; + ((Guid)data!.UserId).Should().Be(userId); + ((Guid)data.ProjectId).Should().Be(projectId); + } + + [Fact] + public async Task MultipleUsers_SameTenant_AllAutoJoinTenantGroup() + { + // Arrange + var tenantId = Guid.NewGuid(); + var user1Id = Guid.NewGuid(); + var user2Id = Guid.NewGuid(); + var user3Id = Guid.NewGuid(); + + var mockGroups = new Mock(); + + var hub1 = CreateTestBaseHub(user1Id, tenantId, "conn-1", mockGroups.Object); + var hub2 = CreateTestBaseHub(user2Id, tenantId, "conn-2", mockGroups.Object); + var hub3 = CreateTestBaseHub(user3Id, tenantId, "conn-3", mockGroups.Object); + + // Act + await hub1.OnConnectedAsync(); + await hub2.OnConnectedAsync(); + await hub3.OnConnectedAsync(); + + // Assert + mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenantId}", default), Times.Once); + mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenantId}", default), Times.Once); + mockGroups.Verify(g => g.AddToGroupAsync("conn-3", $"tenant-{tenantId}", default), Times.Once); + } + + [Fact] + public async Task MultipleUsers_DifferentTenants_JoinDifferentTenantGroups() + { + // Arrange + var tenant1 = Guid.NewGuid(); + var tenant2 = Guid.NewGuid(); + var user1Id = Guid.NewGuid(); + var user2Id = Guid.NewGuid(); + + var mockGroups = new Mock(); + + var hub1 = CreateTestBaseHub(user1Id, tenant1, "conn-1", mockGroups.Object); + var hub2 = CreateTestBaseHub(user2Id, tenant2, "conn-2", mockGroups.Object); + + // Act + await hub1.OnConnectedAsync(); + await hub2.OnConnectedAsync(); + + // Assert + mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenant1}", default), Times.Once); + mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenant2}", default), Times.Once); + + // Verify cross-tenant isolation + mockGroups.Verify(g => g.AddToGroupAsync("conn-1", $"tenant-{tenant2}", default), Times.Never); + mockGroups.Verify(g => g.AddToGroupAsync("conn-2", $"tenant-{tenant1}", default), Times.Never); + } + + [Fact] + public async Task User_SendsTypingStart_ThenStop_SendsBothEvents() + { + // Arrange + var projectId = Guid.NewGuid(); + var issueId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var mockClients = new Mock(); + var mockGroups = new Mock(); + var mockClientProxy = new Mock(); + var mockPermissionService = new Mock(); + + var typingStates = new List(); + + mockClients.Setup(c => c.OthersInGroup(It.IsAny())).Returns(mockClientProxy.Object); + mockClientProxy + .Setup(p => p.SendCoreAsync("TypingIndicator", It.IsAny(), default)) + .Callback((method, args, ct) => + { + var data = args[0] as dynamic; + typingStates.Add((bool)data!.IsTyping); + }) + .Returns(Task.CompletedTask); + + mockPermissionService + .Setup(s => s.IsUserProjectMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var hub = CreateProjectHub(userId, tenantId, "conn-1", mockClients.Object, mockGroups.Object, mockPermissionService.Object); + + // Act + await hub.SendTypingIndicator(projectId, issueId, isTyping: true); + await hub.SendTypingIndicator(projectId, issueId, isTyping: false); + + // Assert + typingStates.Should().HaveCount(2); + typingStates[0].Should().BeTrue(); + typingStates[1].Should().BeFalse(); + } + + // Helper methods + private ProjectHub CreateProjectHub( + Guid userId, + Guid tenantId, + string connectionId, + IHubCallerClients clients, + IGroupManager groups, + IProjectPermissionService permissionService) + { + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()) + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + mockContext.Setup(c => c.ConnectionId).Returns(connectionId); + mockContext.Setup(c => c.ConnectionAborted).Returns(CancellationToken.None); + + return new ProjectHub(permissionService) + { + Context = mockContext.Object, + Clients = clients, + Groups = groups + }; + } + + private TestBaseHub CreateTestBaseHub( + Guid userId, + Guid tenantId, + string connectionId, + IGroupManager groups) + { + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()) + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + mockContext.Setup(c => c.ConnectionId).Returns(connectionId); + + return new TestBaseHub + { + Context = mockContext.Object, + Groups = groups + }; + } + + private class TestBaseHub : BaseHub { } +} diff --git a/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRSecurityTests.cs b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRSecurityTests.cs new file mode 100644 index 0000000..fa7b4f8 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/SignalRSecurityTests.cs @@ -0,0 +1,273 @@ +using System.Security.Claims; +using ColaFlow.API.Hubs; +using FluentAssertions; +using Microsoft.AspNetCore.SignalR; +using Moq; +using Xunit; + +namespace ColaFlow.IntegrationTests.SignalR; + +/// +/// Security tests for SignalR hubs - verifying multi-tenant isolation and authentication +/// +public class SignalRSecurityTests +{ + [Fact] + public void BaseHub_WithoutTenantId_ThrowsUnauthorizedException() + { + // Arrange + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", Guid.NewGuid().ToString()) + // Missing tenant_id claim + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act & Assert + var act = () => hub.GetCurrentTenantIdPublic(); + act.Should().Throw() + .WithMessage("Tenant ID not found in token"); + } + + [Fact] + public void BaseHub_WithoutUserId_ThrowsUnauthorizedException() + { + // Arrange + var mockContext = new Mock(); + var claims = new[] + { + new Claim("tenant_id", Guid.NewGuid().ToString()) + // Missing user_id/sub claim + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act & Assert + var act = () => hub.GetCurrentUserIdPublic(); + act.Should().Throw() + .WithMessage("User ID not found in token"); + } + + [Fact] + public async Task BaseHub_OnConnectedAsync_WithMissingTenantId_AbortsConnection() + { + // Arrange + var mockContext = new Mock(); + var mockGroups = new Mock(); + var userId = Guid.NewGuid(); + + var claims = new[] + { + new Claim("sub", userId.ToString()) + // Missing tenant_id + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + mockContext.Setup(c => c.ConnectionId).Returns("test-connection"); + + var hub = new TestBaseHub + { + Context = mockContext.Object, + Groups = mockGroups.Object + }; + + // Act + await hub.OnConnectedAsync(); + + // Assert + mockContext.Verify(c => c.Abort(), Times.Once); + } + + [Fact] + public async Task BaseHub_OnConnectedAsync_WithMissingUserId_AbortsConnection() + { + // Arrange + var mockContext = new Mock(); + var mockGroups = new Mock(); + var tenantId = Guid.NewGuid(); + + var claims = new[] + { + new Claim("tenant_id", tenantId.ToString()) + // Missing user_id/sub + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + mockContext.Setup(c => c.ConnectionId).Returns("test-connection"); + + var hub = new TestBaseHub + { + Context = mockContext.Object, + Groups = mockGroups.Object + }; + + // Act + await hub.OnConnectedAsync(); + + // Assert + mockContext.Verify(c => c.Abort(), Times.Once); + } + + [Fact] + public void TenantGroupName_ForDifferentTenants_AreDifferent() + { + // Arrange + var tenant1 = Guid.NewGuid(); + var tenant2 = Guid.NewGuid(); + + var mockContext = new Mock(); + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity())); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act + var group1 = hub.GetTenantGroupNamePublic(tenant1); + var group2 = hub.GetTenantGroupNamePublic(tenant2); + + // Assert + group1.Should().NotBe(group2); + group1.Should().Be($"tenant-{tenant1}"); + group2.Should().Be($"tenant-{tenant2}"); + } + + [Fact] + public async Task MultiTenantIsolation_Tenant1User_AutoJoinsOnlyTenant1Group() + { + // Arrange + var tenant1Id = Guid.NewGuid(); + var user1Id = Guid.NewGuid(); + + var mockContext = new Mock(); + var mockGroups = new Mock(); + var connectionId = "connection-1"; + + var claims = new[] + { + new Claim("sub", user1Id.ToString()), + new Claim("tenant_id", tenant1Id.ToString()) + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + mockContext.Setup(c => c.ConnectionId).Returns(connectionId); + + var hub = new TestBaseHub + { + Context = mockContext.Object, + Groups = mockGroups.Object + }; + + // Act + await hub.OnConnectedAsync(); + + // Assert + mockGroups.Verify(g => g.AddToGroupAsync( + connectionId, + $"tenant-{tenant1Id}", + default), Times.Once); + + // Verify NOT added to any other tenant group + mockGroups.Verify(g => g.AddToGroupAsync( + connectionId, + It.Is(s => s != $"tenant-{tenant1Id}"), + default), Times.Never); + } + + [Fact] + public void UserIdExtraction_PrefersSubClaim_OverUserIdClaim() + { + // Arrange + var subClaimId = Guid.NewGuid(); + var userIdClaimId = Guid.NewGuid(); + + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", subClaimId.ToString()), + new Claim("user_id", userIdClaimId.ToString()) + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act + var extractedUserId = hub.GetCurrentUserIdPublic(); + + // Assert + extractedUserId.Should().Be(subClaimId); // Should prefer 'sub' claim + } + + [Fact] + public void InvalidGuidInTenantIdClaim_ThrowsUnauthorizedException() + { + // Arrange + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", Guid.NewGuid().ToString()), + new Claim("tenant_id", "not-a-valid-guid") + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act & Assert + var act = () => hub.GetCurrentTenantIdPublic(); + act.Should().Throw() + .WithMessage("Tenant ID not found in token"); + } + + [Fact] + public void InvalidGuidInUserIdClaim_ThrowsUnauthorizedException() + { + // Arrange + var mockContext = new Mock(); + var claims = new[] + { + new Claim("sub", "invalid-guid-format"), + new Claim("tenant_id", Guid.NewGuid().ToString()) + }; + + mockContext.Setup(c => c.User).Returns(new ClaimsPrincipal(new ClaimsIdentity(claims))); + + var hub = new TestBaseHub + { + Context = mockContext.Object + }; + + // Act & Assert + var act = () => hub.GetCurrentUserIdPublic(); + act.Should().Throw() + .WithMessage("User ID not found in token"); + } + + // Test helper class + private class TestBaseHub : BaseHub + { + public Guid GetCurrentUserIdPublic() => GetCurrentUserId(); + public Guid GetCurrentTenantIdPublic() => GetCurrentTenantId(); + public string GetTenantGroupNamePublic(Guid tenantId) => GetTenantGroupName(tenantId); + } +} diff --git a/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/TestJwtHelper.cs b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/TestJwtHelper.cs new file mode 100644 index 0000000..97fc9d3 --- /dev/null +++ b/colaflow-api/tests/ColaFlow.IntegrationTests/SignalR/TestJwtHelper.cs @@ -0,0 +1,131 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace ColaFlow.IntegrationTests.SignalR; + +/// +/// Helper class for generating JWT tokens for SignalR integration tests +/// +public static class TestJwtHelper +{ + private const string SecretKey = "ColaFlow_Test_Secret_Key_For_SignalR_Integration_Tests_12345"; + private const string Issuer = "ColaFlow.Test"; + private const string Audience = "ColaFlow.Test.Client"; + + public static string GenerateToken(Guid userId, Guid tenantId, int expirationMinutes = 60) + { + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("user_id", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(expirationMinutes), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string GenerateExpiredToken(Guid userId, Guid tenantId) + { + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("user_id", userId.ToString()), + new Claim("tenant_id", tenantId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(-10), // Expired 10 minutes ago + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string GenerateTokenWithoutTenantId(Guid userId) + { + var claims = new[] + { + new Claim("sub", userId.ToString()), + new Claim("user_id", userId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(60), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string GenerateTokenWithoutUserId(Guid tenantId) + { + var claims = new[] + { + new Claim("tenant_id", tenantId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(60), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string GenerateTamperedToken(Guid userId, Guid tenantId) + { + var validToken = GenerateToken(userId, tenantId); + + // Tamper with the token by modifying the middle part + var parts = validToken.Split('.'); + if (parts.Length == 3) + { + // Change a character in the payload + var tamperedPayload = parts[1].Length > 10 + ? parts[1].Substring(0, parts[1].Length - 5) + "XXXXX" + : parts[1] + "XXXXX"; + return $"{parts[0]}.{tamperedPayload}.{parts[2]}"; + } + + return validToken + "TAMPERED"; + } + + public static SecurityKey GetSecurityKey() + { + return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)); + } + + public static string GetIssuer() => Issuer; + public static string GetAudience() => Audience; +} diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/EmailWorkflowsTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/EmailWorkflowsTests.cs index c572d36..e2161a2 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/EmailWorkflowsTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/EmailWorkflowsTests.cs @@ -23,7 +23,7 @@ public class EmailWorkflowsTests(DatabaseFixture fixture) : IClassFixture /// Register a tenant and return access token and tenant ID /// - private async Task<(string accessToken, Guid tenantId)> RegisterTenantAndGetTokenAsync() + private async Task<(string accessToken, Guid tenantId, string tenantSlug)> RegisterTenantAndGetTokenAsync() { var (accessToken, _) = await TestAuthHelper.RegisterAndGetTokensAsync(_client); @@ -592,7 +592,9 @@ public class EmailWorkflowsTests(DatabaseFixture fixture) : IClassFixture c.Type == "tenant_id").Value); - return (accessToken, tenantId); + var tenantSlug = token.Claims.First(c => c.Type == "tenant_slug").Value; + + return (accessToken, tenantId, tenantSlug); } #endregion diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs index 287e803..0990295 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs @@ -22,7 +22,7 @@ public class RoleManagementTests(DatabaseFixture fixture) : IClassFixture /// Register a tenant and return access token and tenant ID /// - private async Task<(string accessToken, Guid tenantId)> RegisterTenantAndGetTokenAsync() + private async Task<(string accessToken, Guid tenantId, string tenantSlug)> RegisterTenantAndGetTokenAsync() { var (accessToken, _) = await TestAuthHelper.RegisterAndGetTokensAsync(_client); @@ -526,7 +526,9 @@ public class RoleManagementTests(DatabaseFixture fixture) : IClassFixture c.Type == "tenant_id").Value); - return (accessToken, tenantId); + var tenantSlug = token.Claims.First(c => c.Type == "tenant_slug").Value; + + return (accessToken, tenantId, tenantSlug); } ///