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); } }