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; /// /// TenantRoutingMiddleware 单元测试 /// /// 测试范围: /// 1. JWT 验证和 TenantId 验证 /// 2. ClusterId 设置(供 Transform 使用) /// 3. TenantId 设置(供 Transform 使用) /// 4. 职责分离验证(Middleware 不负责 Destination 选择) /// 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 jwtTenantId, string headerTenantId) { var context = CreateContext(headerTenantId); var claims = new List { new Claim("tenant", jwtTenantId), new Claim(ClaimTypes.NameIdentifier, "user-1") }; var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); context.User = new ClaimsPrincipal(identity); return context; } #region JWT 验证测试 [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_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"); } #endregion #region ClusterId 和 TenantId 设置测试 [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_WithValidTenantAndRoute_ShouldSetTenantId() { // 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 - TenantId 应该被设置供 Transform 使用 context.Items["TenantId"].Should().Be("tenant-1"); } [Fact] public async Task InvokeAsync_WithValidRoute_ShouldSetBothClusterIdAndTenantId() { // Arrange var routeInfo = new RouteInfo { Id = "1", ClusterId = "cluster-order-service", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-abc", "order-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-abc", path: "/api/order-service/orders"); // Act await middleware.InvokeAsync(context); // Assert context.Items.Should().ContainKey("DynamicClusterId"); context.Items.Should().ContainKey("TenantId"); context.Items["DynamicClusterId"].Should().Be("cluster-order-service"); context.Items["TenantId"].Should().Be("tenant-abc"); } #endregion #region 路由查找测试 [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"); context.Items.Should().NotContainKey("TenantId"); } [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"); } [Theory] [InlineData("/api/user-service/users", "user-service")] [InlineData("/api/order-service/orders", "order-service")] [InlineData("/api/payment-service/", "payment-service")] [InlineData("/api/auth-service", "auth-service")] [InlineData("/api/user_service", "user_service")] [InlineData("/api/UserService123", "UserService123")] [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_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); } #endregion #region 职责分离测试 [Fact] public async Task InvokeAsync_ShouldNotSetDestinationUri() { // Arrange - Middleware 不应该设置 Destination 相关信息 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 - Middleware 不应该设置任何 Destination 相关的 Items // 这些应该由 TenantRoutingTransform 处理 context.Items.Should().NotContainKey("DestinationUri"); context.Items.Should().NotContainKey("TargetAddress"); } [Fact] public async Task InvokeAsync_ShouldOnlySetClusterIdAndTenantId() { // Arrange - 验证 Middleware 只负责设置 ClusterId 和 TenantId var routeInfo = new RouteInfo { Id = "1", ClusterId = "cluster-payment", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-x", "payment")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: "tenant-x", path: "/api/payment/process"); // Act await middleware.InvokeAsync(context); // Assert - 只设置 ClusterId 和 TenantId context.Items.Count.Should().Be(2); context.Items.Should().ContainKey("DynamicClusterId"); context.Items.Should().ContainKey("TenantId"); } [Fact] public async Task InvokeAsync_WithGlobalRoute_ShouldSetClusterIdWithoutDestinationSelection() { // Arrange - 全局路由也应该只设置 ClusterId,不处理 Destination 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 - 只设置 ClusterId,Destination 选择由 Transform 处理 context.Items["DynamicClusterId"].Should().Be("global-cluster"); context.Items["TenantId"].Should().Be("tenant-1"); } #endregion #region 与 Transform 协作测试 [Fact] public async Task InvokeAsync_ShouldSetItemsForTransformConsumption() { // Arrange - 模拟 Transform 从 Items 读取 ClusterId 和 TenantId var routeInfo = new RouteInfo { Id = "1", ClusterId = "cluster-inventory", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute("tenant-cooperation", "inventory")) .Returns(routeInfo); string? capturedClusterId = null; string? capturedTenantId = null; var nextDelegate = new RequestDelegate(ctx => { // 模拟 Transform 的行为:从 Items 读取 capturedClusterId = ctx.Items.TryGetValue("DynamicClusterId", out var c) ? c as string : null; capturedTenantId = ctx.Items.TryGetValue("TenantId", out var t) ? t as string : null; return Task.CompletedTask; }); var middleware = CreateMiddleware(next: nextDelegate); var context = CreateContext(tenantId: "tenant-cooperation", path: "/api/inventory/items"); // Act await middleware.InvokeAsync(context); // Assert - 验证 Transform 可以正确读取 Middleware 设置的值 capturedClusterId.Should().Be("cluster-inventory"); capturedTenantId.Should().Be("tenant-cooperation"); } [Fact] public async Task InvokeAsync_WhenRouteNotFound_ShouldNotSetItems() { // Arrange _routeCacheMock .Setup(x => x.GetRoute("unknown-tenant", "user-service")) .Returns((RouteInfo?)null); var nextDelegate = new RequestDelegate(ctx => { // 模拟 Transform 检查 Items ctx.Items.ContainsKey("DynamicClusterId").Should().BeFalse(); ctx.Items.ContainsKey("TenantId").Should().BeFalse(); return Task.CompletedTask; }); var middleware = CreateMiddleware(next: nextDelegate); var context = CreateContext(tenantId: "unknown-tenant"); // Act await middleware.InvokeAsync(context); // Assert - Transform 应该跳过处理(因为没有 ClusterId) } #endregion #region 边界条件测试 [Fact] public async Task InvokeAsync_WithTenantRoute_ShouldWorkCorrectly() { // 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_WithSpecialCharactersInTenantId_ShouldSetTenantId() { // Arrange - 测试特殊字符的 TenantId var specialTenantId = "tenant-123_abc.XYZ"; var routeInfo = new RouteInfo { Id = "1", ClusterId = "cluster-1", IsGlobal = false }; _routeCacheMock .Setup(x => x.GetRoute(specialTenantId, "user-service")) .Returns(routeInfo); var middleware = CreateMiddleware(); var context = CreateContext(tenantId: specialTenantId); // Act await middleware.InvokeAsync(context); // Assert context.Items["TenantId"].Should().Be(specialTenantId); } #endregion }