- TestFixture: Base test infrastructure with WebApplicationFactory - K8sDiscoveryTests: K8s Service Label discovery flow tests - ConfigConfirmationTests: Pending config confirmation flow tests - MultiTenantRoutingTests: Tenant-specific vs default destination routing tests - ConfigReloadTests: Gateway hot-reload via NOTIFY mechanism tests - TestData: Mock data for K8s services, JWT tokens, database seeding Tests cover: 1. K8s Service discovery with valid labels 2. Config confirmation -> DB write -> NOTIFY 3. Multi-tenant routing (dedicated vs default destination) 4. Gateway config hot-reload without restart
405 lines
12 KiB
C#
405 lines
12 KiB
C#
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
|
|
using Fengling.Platform.Domain.AggregatesModel.TenantAggregate;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using YarpGateway.Data;
|
|
using YarpGateway.Models;
|
|
|
|
namespace YarpGateway.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// 集成测试数据准备
|
|
/// </summary>
|
|
public static class TestData
|
|
{
|
|
#region 租户数据
|
|
|
|
public static async Task SeedTenantsAsync(GatewayDbContext dbContext)
|
|
{
|
|
var tenants = new List<Tenant>
|
|
{
|
|
new()
|
|
{
|
|
TenantCode = "tenant1",
|
|
Name = "Tenant One",
|
|
Status = TenantStatus.Active,
|
|
CreatedAt = DateTime.UtcNow
|
|
},
|
|
new()
|
|
{
|
|
TenantCode = "tenant2",
|
|
Name = "Tenant Two",
|
|
Status = TenantStatus.Active,
|
|
CreatedAt = DateTime.UtcNow
|
|
},
|
|
new()
|
|
{
|
|
TenantCode = "default",
|
|
Name = "Default Tenant",
|
|
Status = TenantStatus.Active,
|
|
CreatedAt = DateTime.UtcNow
|
|
}
|
|
};
|
|
|
|
foreach (var tenant in tenants)
|
|
{
|
|
if (!dbContext.Tenants.Any(t => t.TenantCode == tenant.TenantCode))
|
|
{
|
|
dbContext.Tenants.Add(tenant);
|
|
}
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 路由数据
|
|
|
|
public static async Task SeedRoutesAsync(GatewayDbContext dbContext)
|
|
{
|
|
var routes = new List<GwTenantRoute>
|
|
{
|
|
// 全局路由 - member 服务
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
TenantCode = "",
|
|
ServiceName = "member",
|
|
ClusterId = "member-cluster",
|
|
Match = new GwRouteMatch { Path = "/api/member/**" },
|
|
Priority = 1,
|
|
Status = 1,
|
|
IsGlobal = true,
|
|
IsDeleted = false,
|
|
CreatedTime = DateTime.UtcNow
|
|
},
|
|
// 全局路由 - order 服务
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
TenantCode = "",
|
|
ServiceName = "order",
|
|
ClusterId = "order-cluster",
|
|
Match = new GwRouteMatch { Path = "/api/order/**" },
|
|
Priority = 1,
|
|
Status = 1,
|
|
IsGlobal = true,
|
|
IsDeleted = false,
|
|
CreatedTime = DateTime.UtcNow
|
|
},
|
|
// 租户专属路由 - tenant1 的 member 服务
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
TenantCode = "tenant1",
|
|
ServiceName = "member",
|
|
ClusterId = "tenant1-member-cluster",
|
|
Match = new GwRouteMatch { Path = "/api/member/**" },
|
|
Priority = 0, // 更高优先级
|
|
Status = 1,
|
|
IsGlobal = false,
|
|
IsDeleted = false,
|
|
CreatedTime = DateTime.UtcNow
|
|
}
|
|
};
|
|
|
|
foreach (var route in routes)
|
|
{
|
|
if (!dbContext.GwTenantRoutes.Any(r => r.Id == route.Id))
|
|
{
|
|
dbContext.GwTenantRoutes.Add(route);
|
|
}
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 集群和目标数据
|
|
|
|
public static async Task SeedClustersAsync(GatewayDbContext dbContext)
|
|
{
|
|
var clusters = new List<GwCluster>
|
|
{
|
|
// member-cluster - 默认集群
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
ClusterId = "member-cluster",
|
|
Name = "Member Service Cluster",
|
|
LoadBalancingPolicy = GwLoadBalancingPolicy.RoundRobin,
|
|
Status = 1,
|
|
CreatedTime = DateTime.UtcNow,
|
|
Destinations = new List<GwDestination>
|
|
{
|
|
new()
|
|
{
|
|
DestinationId = "default",
|
|
Address = "http://default-member:8080",
|
|
Weight = 1,
|
|
Status = 1,
|
|
TenantCode = null // 默认目标
|
|
}
|
|
}
|
|
},
|
|
// tenant1-member-cluster - tenant1 专属集群
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
ClusterId = "tenant1-member-cluster",
|
|
Name = "Tenant1 Member Service Cluster",
|
|
LoadBalancingPolicy = GwLoadBalancingPolicy.RoundRobin,
|
|
Status = 1,
|
|
CreatedTime = DateTime.UtcNow,
|
|
Destinations = new List<GwDestination>
|
|
{
|
|
new()
|
|
{
|
|
DestinationId = "tenant1-dest",
|
|
Address = "http://tenant1-member:8080",
|
|
Weight = 1,
|
|
Status = 1,
|
|
TenantCode = "tenant1" // 租户专属目标
|
|
}
|
|
}
|
|
},
|
|
// order-cluster - 默认集群
|
|
new()
|
|
{
|
|
Id = Guid.CreateVersion7().ToString("N"),
|
|
ClusterId = "order-cluster",
|
|
Name = "Order Service Cluster",
|
|
LoadBalancingPolicy = GwLoadBalancingPolicy.RoundRobin,
|
|
Status = 1,
|
|
CreatedTime = DateTime.UtcNow,
|
|
Destinations = new List<GwDestination>
|
|
{
|
|
new()
|
|
{
|
|
DestinationId = "default",
|
|
Address = "http://default-order:8080",
|
|
Weight = 1,
|
|
Status = 1,
|
|
TenantCode = null // 默认目标
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
foreach (var cluster in clusters)
|
|
{
|
|
if (!dbContext.GwClusters.Any(c => c.ClusterId == cluster.ClusterId))
|
|
{
|
|
dbContext.GwClusters.Add(cluster);
|
|
}
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region K8s Service 模拟数据
|
|
|
|
/// <summary>
|
|
/// 创建模拟的 K8s Service 发现数据
|
|
/// </summary>
|
|
public static GwPendingServiceDiscovery CreateK8sService(
|
|
string serviceName,
|
|
string @namespace,
|
|
Dictionary<string, string> labels,
|
|
string clusterIp = "10.96.123.45",
|
|
int podCount = 1)
|
|
{
|
|
return new GwPendingServiceDiscovery
|
|
{
|
|
K8sServiceName = serviceName,
|
|
K8sNamespace = @namespace,
|
|
K8sClusterIP = clusterIp,
|
|
Labels = JsonSerializer.Serialize(labels),
|
|
DiscoveredPorts = "[8080]",
|
|
PodCount = podCount,
|
|
Status = (int)PendingConfigStatus.Pending,
|
|
DiscoveredAt = DateTime.UtcNow,
|
|
Version = 1,
|
|
IsDeleted = false
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建带路由标签的 K8s Service
|
|
/// </summary>
|
|
public static GwPendingServiceDiscovery CreateRoutedK8sService(
|
|
string serviceName,
|
|
string prefix,
|
|
string clusterName,
|
|
string destination = "default",
|
|
string @namespace = "default")
|
|
{
|
|
return CreateK8sService(
|
|
serviceName,
|
|
@namespace,
|
|
new Dictionary<string, string>
|
|
{
|
|
["app-router-name"] = serviceName,
|
|
["app-router-prefix"] = prefix,
|
|
["app-cluster-name"] = clusterName,
|
|
["app-cluster-destination"] = destination
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region JWT Token 生成
|
|
|
|
private static readonly SymmetricSecurityKey TestSigningKey = new(
|
|
Encoding.UTF8.GetBytes("test-signing-key-that-is-long-enough-for-hs256-algorithm"));
|
|
|
|
/// <summary>
|
|
/// 生成测试用 JWT Token
|
|
/// </summary>
|
|
public static string GenerateJwtToken(
|
|
string userId,
|
|
string tenantCode,
|
|
string[]? roles = null,
|
|
DateTime? expires = null)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, userId),
|
|
new("sub", userId),
|
|
new("tenant", tenantCode),
|
|
new("tenant_id", tenantCode),
|
|
new(ClaimTypes.Name, $"test-user-{userId}"),
|
|
new("name", $"Test User {userId}")
|
|
};
|
|
|
|
if (roles != null)
|
|
{
|
|
foreach (var role in roles)
|
|
{
|
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
|
claims.Add(new Claim("role", role));
|
|
}
|
|
}
|
|
|
|
var tokenDescriptor = new SecurityTokenDescriptor
|
|
{
|
|
Subject = new ClaimsIdentity(claims),
|
|
Expires = expires ?? DateTime.UtcNow.AddHours(1),
|
|
Issuer = "test-issuer",
|
|
Audience = "test-audience",
|
|
SigningCredentials = new SigningCredentials(TestSigningKey, SecurityAlgorithms.HmacSha256)
|
|
};
|
|
|
|
var tokenHandler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
|
|
var token = tokenHandler.CreateToken(tokenDescriptor);
|
|
return tokenHandler.WriteToken(token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取 tenant1 的测试 Token
|
|
/// </summary>
|
|
public static string GetTenant1Token(string userId = "user1")
|
|
{
|
|
return GenerateJwtToken(userId, "tenant1", new[] { "user" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取 tenant2 的测试 Token
|
|
/// </summary>
|
|
public static string GetTenant2Token(string userId = "user2")
|
|
{
|
|
return GenerateJwtToken(userId, "tenant2", new[] { "user" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取默认租户的测试 Token
|
|
/// </summary>
|
|
public static string GetDefaultToken(string userId = "default-user")
|
|
{
|
|
return GenerateJwtToken(userId, "default", new[] { "user" });
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
#region 支持模型
|
|
|
|
/// <summary>
|
|
/// 待确认配置状态
|
|
/// </summary>
|
|
public enum PendingConfigStatus
|
|
{
|
|
Pending = 0,
|
|
Confirmed = 1,
|
|
Rejected = 2
|
|
}
|
|
|
|
/// <summary>
|
|
/// PendingServiceDiscovery 扩展方法
|
|
/// </summary>
|
|
public static class PendingServiceDiscoveryExtensions
|
|
{
|
|
/// <summary>
|
|
/// 获取解析后的标签
|
|
/// </summary>
|
|
public static Dictionary<string, string> GetParsedLabels(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(discovery.Labels) ?? new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取路由名称
|
|
/// </summary>
|
|
public static string GetRouteName(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
var labels = GetParsedLabels(discovery);
|
|
return labels.GetValueOrDefault("app-router-name", discovery.K8sServiceName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取路由前缀
|
|
/// </summary>
|
|
public static string GetRoutePrefix(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
var labels = GetParsedLabels(discovery);
|
|
return labels.GetValueOrDefault("app-router-prefix", $"/api/{discovery.K8sServiceName}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取集群名称
|
|
/// </summary>
|
|
public static string GetClusterName(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
var labels = GetParsedLabels(discovery);
|
|
return labels.GetValueOrDefault("app-cluster-name", discovery.K8sServiceName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取目标 ID
|
|
/// </summary>
|
|
public static string GetDestinationId(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
var labels = GetParsedLabels(discovery);
|
|
return labels.GetValueOrDefault("app-cluster-destination", "default");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 构建服务地址
|
|
/// </summary>
|
|
public static string BuildAddress(this GwPendingServiceDiscovery discovery)
|
|
{
|
|
var host = discovery.K8sClusterIP ?? $"{discovery.K8sServiceName}.{discovery.K8sNamespace}";
|
|
var ports = JsonSerializer.Deserialize<int[]>(discovery.DiscoveredPorts) ?? new[] { 8080 };
|
|
var port = ports.FirstOrDefault(8080);
|
|
return $"http://{host}:{port}";
|
|
}
|
|
}
|
|
|
|
#endregion
|