职责分离: - 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个,全部通过
548 lines
16 KiB
C#
548 lines
16 KiB
C#
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 - 只设置 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
|
||
}
|