using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Logging; using Moq; using Xunit; using FluentAssertions; using YarpGateway.Middleware; using YarpGateway.Services; namespace YarpGateway.Tests.Unit.Middleware; public class TenantRoutingMiddlewareTests { private readonly Mock _routeCacheMock; private readonly Mock> _loggerMock; private readonly RequestDelegate _nextDelegate; public TenantRoutingMiddlewareTests() { _routeCacheMock = new Mock(); _loggerMock = new Mock>(); // Default: call next _nextDelegate = _ => Task.CompletedTask; } private TenantRoutingMiddleware CreateMiddleware( IRouteCache? routeCache = null, RequestDelegate? next = null) { return new TenantRoutingMiddleware( next: next ?? _nextDelegate, routeCache: routeCache ?? _routeCacheMock.Object, logger: _loggerMock.Object ); } private DefaultHttpContext CreateContext(string? tenantId = null, string path = "/api/user-service/users") { var context = new DefaultHttpContext { Request = { Path = path } }; if (!string.IsNullOrEmpty(tenantId)) { context.Request.Headers["X-Tenant-Id"] = tenantId; } return context; } private DefaultHttpContext CreateAuthenticatedContext(string tenantId, string headerTenantId) { var context = CreateContext(headerTenantId); var claims = new List { new Claim("tenant", tenantId), new Claim(ClaimTypes.NameIdentifier, "user-1") }; var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); context.User = new ClaimsPrincipal(identity); return context; } [Fact] public async Task InvokeAsync_WithoutTenantHeader_ShouldCallNext() { // Arrange var nextCalled = false; var middleware = CreateMiddleware(next: _ => { nextCalled = true; return Task.CompletedTask; }); var context = CreateContext(tenantId: null); // Act await middleware.InvokeAsync(context); // Assert nextCalled.Should().BeTrue(); } [Fact] public async Task InvokeAsync_WithValidTenantAndRoute_ShouldSetClusterId() { // Arrange var routeInfo = new RouteInfo { Id = 1, ClusterId = "cluster-user-service", PathPattern = "/api/user-service/**", Priority = 1, IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert context.Items["DynamicClusterId"].Should().Be("cluster-user-service"); } [Fact] public async Task InvokeAsync_WhenRouteNotFound_ShouldCallNext() { // Arrange _routeCacheMock .Setup(x => x.GetRoute(It.IsAny(), It.IsAny())) .Returns((RouteInfo?)null); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert - should not throw, just continue context.Items.Should().NotContainKey("DynamicClusterId"); } [Fact] public async Task InvokeAsync_WithTenantIdMismatch_ShouldReturn403() { // Arrange // JWT has tenant-1, but header has tenant-2 var middleware = CreateMiddleware(); var context = CreateAuthenticatedContext("tenant-1", "tenant-2"); // Act await middleware.InvokeAsync(context); // Assert context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); } [Fact] public async Task InvokeAsync_WithMatchingTenant_ShouldAllowRequest() { // Arrange var routeInfo = new RouteInfo { Id = 1, ClusterId = "cluster-1", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateAuthenticatedContext("tenant-1", "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden); context.Items["DynamicClusterId"].Should().Be("cluster-1"); } [Fact] public async Task InvokeAsync_WithoutAuthentication_ShouldAllowRequest() { // Arrange var routeInfo = new RouteInfo { Id = 1, ClusterId = "cluster-1", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // No authentication set // Act await middleware.InvokeAsync(context); // Assert - should proceed without 403 context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden); context.Items["DynamicClusterId"].Should().Be("cluster-1"); } [Theory] [InlineData("/api/user-service/users", "user-service")] [InlineData("/api/order-service/orders", "order-service")] [InlineData("/api/payment/", "payment")] [InlineData("/api/auth", "auth")] [InlineData("/other/path", "")] public async Task InvokeAsync_ShouldExtractServiceNameFromPath(string path, string expectedServiceName) { // Arrange var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1", path: path); // Act await middleware.InvokeAsync(context); // Assert if (!string.IsNullOrEmpty(expectedServiceName)) { _routeCacheMock.Verify( x => x.GetRoute("tenant-1", expectedServiceName), Times.Once); } } [Fact] public async Task InvokeAsync_WithTenantRoute_ShouldLogAsTenantSpecific() { // Arrange var routeInfo = new RouteInfo { Id = 1, ClusterId = "cluster-1", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert - just verify it completes without error context.Items["DynamicClusterId"].Should().Be("cluster-1"); } [Fact] public async Task InvokeAsync_WithGlobalRoute_ShouldLogAsGlobal() { // Arrange var routeInfo = new RouteInfo { Id = 1, ClusterId = "global-cluster", IsGlobal = true }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert context.Items["DynamicClusterId"].Should().Be("global-cluster"); } [Fact] public async Task InvokeAsync_WithEmptyPath_ShouldCallNext() { // Arrange var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1", path: ""); // Act await middleware.InvokeAsync(context); // Assert - should not try to extract service name _routeCacheMock.Verify( x => x.GetRoute(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task InvokeAsync_PrioritizesTenantRouteOverGlobal() { // Arrange - This test verifies the middleware calls GetRoute with tenant code // The priority logic is in RouteCache, not in middleware var tenantRoute = new RouteInfo { Id = 1, ClusterId = "tenant-specific-cluster", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-1", "user-service")) .Returns(tenantRoute); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-1"); // Act await middleware.InvokeAsync(context); // Assert context.Items["DynamicClusterId"].Should().Be("tenant-specific-cluster"); } }