fengling-gateway/tests/YarpGateway.Tests/Unit/Middleware/TenantRoutingMiddlewareTests.cs
movingsam 4fd931d44b IMPL-11: 更新 TenantRoutingMiddleware 适配新 Transform
职责分离:
- Middleware: 负责 JWT 验证、TenantId 验证、设置 ClusterId 和 TenantId 到 HttpContext.Items
- Transform: 负责从数据库查询 Destination 并设置 ProxyRequest.RequestUri

修改内容:
1. TenantRoutingMiddleware:
   - 添加设置 TenantId 到 HttpContext.Items 供 Transform 使用
   - 修复服务名提取正则表达式,支持连字符(-)和下划线(_)
   - 更新 XML 文档,明确职责分离说明

2. TenantRoutingTransform:
   - 添加 ExtractTenantId 方法,优先从 HttpContext.Items 获取 TenantId
   - 保留从 JWT 提取作为回退机制

3. 单元测试:
   - 新增职责分离验证测试 (ShouldNotSetDestinationUri, ShouldOnlySetClusterIdAndTenantId)
   - 新增与 Transform 协作测试 (ShouldSetItemsForTransformConsumption)
   - 更新服务名提取测试,支持更多字符类型
   - 总测试数: 24个,全部通过
2026-03-08 01:20:20 +08:00

548 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// TenantRoutingMiddleware 单元测试
///
/// 测试范围:
/// 1. JWT 验证和 TenantId 验证
/// 2. ClusterId 设置(供 Transform 使用)
/// 3. TenantId 设置(供 Transform 使用)
/// 4. 职责分离验证Middleware 不负责 Destination 选择)
/// </summary>
public class TenantRoutingMiddlewareTests
{
private readonly Mock<IRouteCache> _routeCacheMock;
private readonly Mock<ILogger<TenantRoutingMiddleware>> _loggerMock;
private readonly RequestDelegate _nextDelegate;
public TenantRoutingMiddlewareTests()
{
_routeCacheMock = new Mock<IRouteCache>();
_loggerMock = new Mock<ILogger<TenantRoutingMiddleware>>();
// 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<Claim>
{
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<string>(), It.IsAny<string>()))
.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<string>(), It.IsAny<string>()),
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 - 只设置 ClusterIdDestination 选择由 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
}