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