docs: add security audit and test plan

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
movingsam 2026-02-28 18:38:38 +08:00
parent 5755b41664
commit 52f4b7616e
9 changed files with 1890 additions and 0 deletions

View File

@ -0,0 +1,368 @@
# 🔒 YARP 网关安全审计报告
> 审计日期2026-02-28
> 审计范围:认证授权、注入漏洞、敏感信息、访问控制、配置安全
---
## 执行摘要
| 严重程度 | 数量 |
|---------|------|
| 🔴 严重 (CRITICAL) | 3 |
| 🟠 高危 (HIGH) | 3 |
| 🟡 中危 (MEDIUM) | 4 |
| 🟢 低危 (LOW) | 3 |
| **总计** | **13** |
---
## 🔴 严重漏洞
### 1. 硬编码数据库凭据泄露
**文件:** `src/appsettings.json` 第 19 行
**问题代码:**
```json
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
```
**攻击场景:**
- 代码泄露或被推送到公开仓库时,攻击者直接获得数据库完整访问权限
- 可读取、修改、删除所有业务数据
**修复建议:**
```csharp
// 使用环境变量或 Secret Manager
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// 或使用 Azure Key Vault / AWS Secrets Manager
```
---
### 2. 硬编码 Redis 凭据泄露
**文件:** `src/Config/RedisConfig.cs` 第 5 行
**问题代码:**
```csharp
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
```
**攻击场景:**
- 攻击者可连接 Redis 服务器,读取缓存数据、修改路由配置、注入恶意数据
**修复建议:**
```csharp
public string ConnectionString { get; set; } = string.Empty;
// 从环境变量或配置中心读取
```
---
### 3. 管理 API 完全无认证保护
**文件:** `src/Controllers/GatewayConfigController.cs``src/Controllers/PendingServicesController.cs`
**问题描述:**
- 所有 API 端点均无 `[Authorize]` 特性
- `Program.cs` 中未配置 `AddAuthentication()``UseAuthentication()`
- 项目搜索未发现任何认证中间件
**攻击场景:**
```
# 攻击者可直接调用以下 API
POST /api/gateway/tenants # 创建任意租户
DELETE /api/gateway/tenants/{id} # 删除租户
POST /api/gateway/routes # 创建恶意路由
POST /api/gateway/config/reload # 重载配置
DELETE /api/gateway/clusters/{id} # 删除服务集群
```
**修复建议:**
```csharp
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* 配置 JWT 验证 */ });
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
// Controllers
[ApiController]
[Route("api/gateway")]
[Authorize] // 添加认证要求
public class GatewayConfigController : ControllerBase
```
---
## 🟠 高危漏洞
### 4. JWT 签名验证缺失
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 39-40 行
**问题代码:**
```csharp
var jwtHandler = new JwtSecurityTokenHandler();
var jwtToken = jwtHandler.ReadJwtToken(token); // 仅读取,不验证!
```
**攻击场景:**
```python
# 攻击者可伪造任意 JWT
import jwt
fake_token = jwt.encode({"tenant": "admin-tenant", "sub": "admin"}, "any_secret", algorithm="HS256")
# 网关会接受这个伪造的 token
```
**修复建议:**
```csharp
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = _jwtConfig.Authority,
ValidAudience = _jwtConfig.Audience,
IssuerSigningKey = /* 从 Authority 获取公钥 */
};
var principal = jwtHandler.ValidateToken(token, validationParameters, out _);
```
---
### 5. 租户隔离可被 Header 注入绕过
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 54 行
**问题代码:**
```csharp
context.Request.Headers["X-Tenant-Id"] = tenantId;
```
**攻击场景:**
```bash
# 攻击者直接注入 Header 绕过 JWT
curl -H "X-Tenant-Id: target-tenant" \
-H "X-User-Id: admin" \
-H "X-Roles: admin" \
https://gateway/api/sensitive-data
```
**修复建议:**
```csharp
// 在中间件开始时移除所有 X-* Header
foreach (var header in context.Request.Headers.Where(h => h.Key.StartsWith("X-")).ToList())
{
context.Request.Headers.Remove(header.Key);
}
// 然后再从 JWT 设置可信的 header
```
---
### 6. 租户路由信息泄露
**文件:** `src/Middleware/TenantRoutingMiddleware.cs` 第 44 行
**问题代码:**
```csharp
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
```
**攻击场景:**
- 日志中记录租户 ID 和服务名,攻击者可通过日志收集系统架构信息
- 配合其他攻击进行侦察
**修复建议:**
- 敏感信息不应记录到普通日志
- 使用脱敏处理或仅记录哈希值
---
## 🟡 中危漏洞
### 7. 日志记录敏感连接信息
**文件:** `src/Services/RedisConnectionManager.cs` 第 44 行
**问题代码:**
```csharp
_logger.LogInformation("Connected to Redis at {ConnectionString}", _config.ConnectionString);
```
**修复建议:**
```csharp
_logger.LogInformation("Connected to Redis at {Host}",
configuration.EndPoints.FirstOrDefault()?.ToString());
```
---
### 8. CORS 凭据配置存在风险
**文件:** `src/Program.cs` 第 89-100 行
**问题代码:**
```csharp
if (allowAnyOrigin)
{
policy.AllowAnyOrigin();
}
// ...
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // 与 AllowAnyOrigin 不兼容
```
**修复建议:**
```csharp
if (allowAnyOrigin)
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
// 不允许 AllowCredentials
}
else
{
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
```
---
### 9. 健康检查端点信息泄露
**文件:** `src/Program.cs` 第 115 行
**修复建议:**
```csharp
// 添加访问限制或使用标准健康检查
builder.Services.AddHealthChecks();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (c, r) =>
await c.Response.WriteAsync("healthy")
});
```
---
### 10. JWT Authority 使用占位符 URL
**文件:** `src/appsettings.json` 第 22 行
**问题代码:**
```json
"Authority": "https://your-auth-server.com"
```
**修复建议:**
- 强制要求配置有效的 Authority URL
- 启动时验证配置有效性
---
## 🟢 低危漏洞
### 11. 可预测的 ID 生成
**文件:** `src/Controllers/GatewayConfigController.cs` 第 484-487 行
**问题代码:**
```csharp
private long GenerateId()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
```
**修复建议:**
```csharp
// 使用 GUID 或雪花算法
private long GenerateId() => SnowflakeIdGenerator.NextId();
// 或
private string GenerateId() => Guid.NewGuid().ToString("N");
```
---
### 12. 缺少输入验证
**文件:** `src/Controllers/GatewayConfigController.cs` 多处
**修复建议:**
```csharp
public class CreateTenantDto
{
[Required]
[RegularExpression(@"^[a-zA-Z0-9-]{1,50}$")]
public string TenantCode { get; set; } = string.Empty;
[Required]
[StringLength(100, MinimumLength = 1)]
public string TenantName { get; set; } = string.Empty;
}
```
---
### 13. 错误消息暴露内部信息
**文件:** `src/Controllers/PendingServicesController.cs` 第 116 行
**修复建议:**
```csharp
return BadRequest(new { message = "Invalid cluster configuration" });
```
---
## 📋 修复优先级建议
| 优先级 | 漏洞编号 | 修复时间建议 |
|-------|---------|------------|
| P0 (立即) | #1, #2, #3 | 24小时内 |
| P1 (紧急) | #4, #5, #6 | 1周内 |
| P2 (重要) | #7, #8, #9, #10 | 2周内 |
| P3 (一般) | #11, #12, #13 | 1个月内 |
---
## 🛡️ 安全加固建议
### 1. 认证授权
- 实施完整的 JWT 验证流程
- 为所有管理 API 添加 `[Authorize]`
- 实施基于角色的访问控制 (RBAC)
### 2. 配置安全
- 使用 Azure Key Vault / AWS Secrets Manager 管理密钥
- 移除所有硬编码凭据
- 生产环境禁用调试模式
### 3. 租户隔离
- 在网关层强制验证租户归属
- 使用加密签名验证内部 Header
- 实施租户数据隔离审计
### 4. 日志安全
- 敏感信息脱敏
- 限制日志访问权限
- 使用结构化日志便于审计
---
*报告由安全审计生成,建议人工复核后纳入迭代计划。*

View File

@ -0,0 +1,180 @@
# 🧪 YARP 网关测试覆盖计划
> 分析日期2026-02-28
> 当前状态:**无任何测试代码**
---
## 测试项目结构
```
tests/
└── YarpGateway.Tests/
├── YarpGateway.Tests.csproj
├── Unit/
│ ├── Middleware/
│ │ ├── JwtTransformMiddlewareTests.cs
│ │ └── TenantRoutingMiddlewareTests.cs
│ ├── Services/
│ │ ├── RouteCacheTests.cs
│ │ ├── RedisConnectionManagerTests.cs
│ │ └── PgSqlConfigChangeListenerTests.cs
│ ├── LoadBalancing/
│ │ └── DistributedWeightedRoundRobinPolicyTests.cs
│ └── Config/
│ ├── DatabaseRouteConfigProviderTests.cs
│ └── DatabaseClusterConfigProviderTests.cs
├── Integration/
│ ├── Controllers/
│ │ ├── GatewayConfigControllerTests.cs
│ │ └── PendingServicesControllerTests.cs
│ └── Middleware/
│ └── MiddlewarePipelineTests.cs
└── TestHelpers/
├── MockDbContext.cs
├── MockRedis.cs
└── TestFixtures.cs
```
---
## P0 - 必须覆盖(核心安全)
### JwtTransformMiddlewareTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldValidateJwtSignature` | 应验证 JWT 签名 | 有效签名的 JWT | 解析成功Claims 正确 | `IOptions<JwtConfig>` |
| `ShouldRejectInvalidToken` | 应拒绝无效 Token | 伪造/过期 JWT | 返回 401 或跳过处理 | - |
| `ShouldExtractTenantClaim` | 应正确提取租户 ID | 含 tenant claim 的 JWT | X-Tenant-Id header 设置正确 | - |
| `ShouldHandleMissingToken` | 应处理无 Token 请求 | 无 Authorization header | 继续处理(不设置 headers | - |
| `ShouldHandleMalformedToken` | 应处理格式错误 Token | 无效 JWT 格式 | 记录警告,继续处理 | - |
### TenantRoutingMiddlewareTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldValidateTenantIdAgainstJwt` | 应验证 header 与 JWT 一致 | X-Tenant-Id ≠ JWT tenant | 返回 403 Forbidden | `IRouteCache` |
| `ShouldExtractServiceNameFromPath` | 应正确解析服务名 | `/api/user-service/users` | serviceName = "user-service" | - |
| `ShouldFindRouteInCache` | 应从缓存找到路由 | 有效租户+服务名 | 设置正确的 clusterId | `IRouteCache` |
| `ShouldHandleRouteNotFound` | 应处理路由未找到 | 不存在的服务名 | 记录警告,继续处理 | - |
| `ShouldPrioritizeTenantRouteOverGlobal` | 租户路由优先于全局 | 同时存在两种路由 | 使用租户路由 | - |
---
## P1 - 重要覆盖(核心业务)
### RouteCacheTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldLoadGlobalRoutes` | 应加载全局路由 | 全局路由数据 | `_globalRoutes` 填充 | `IDbContextFactory<GatewayDbContext>` |
| `ShouldLoadTenantRoutes` | 应加载租户路由 | 租户路由数据 | `_tenantRoutes` 填充 | - |
| `ShouldReturnCorrectRoute` | 应返回正确路由 | 查询请求 | 正确的 `RouteInfo` | - |
| `ShouldReturnNullForMissingRoute` | 不存在路由返回 null | 不存在的服务名 | `null` | - |
| `ShouldHandleConcurrentReads` | 并发读取应安全 | 多线程读取 | 无异常,数据一致 | - |
| `ShouldReloadCorrectly` | 应正确重载 | Reload 调用 | 旧数据清除,新数据加载 | - |
### RedisConnectionManagerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldAcquireLock` | 应获取分布式锁 | 有效 key | 锁获取成功 | `IConnectionMultiplexer` |
| `ShouldReleaseLockCorrectly` | 应正确释放锁 | 已获取的锁 | 锁释放成功 | - |
| `ShouldNotReleaseOthersLock` | 不应释放他人锁 | 其他实例的锁 | 释放失败(安全) | - |
| `ShouldHandleConnectionFailure` | 应处理连接失败 | Redis 不可用 | 记录错误,返回失败 | - |
| `ShouldExecuteInLock` | 应在锁内执行操作 | 操作委托 | 操作执行,锁正确释放 | - |
### DistributedWeightedRoundRobinPolicyTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldSelectByWeight` | 应按权重选择 | 权重 [3, 1, 1] | 约 60% 选第一个 | `IConnectionMultiplexer` |
| `ShouldFallbackOnLockFailure` | 锁失败应降级 | Redis 不可用 | 降级选择第一个可用 | - |
| `ShouldReturnNullWhenNoDestinations` | 无目标返回 null | 空目标列表 | `null` | - |
| `ShouldPersistStateToRedis` | 状态应持久化到 Redis | 多次选择 | 状态存储正确 | - |
| `ShouldExpireStateAfterTTL` | 状态应在 TTL 后过期 | 1 小时后 | 状态重新初始化 | - |
### PgSqlConfigChangeListenerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldListenForNotifications` | 应监听通知 | NOTIFY 事件 | 触发重载 | `NpgsqlConnection` |
| `ShouldFallbackToPolling` | 应回退到轮询 | 通知失败 | 定时轮询检测 | - |
| `ShouldReconnectOnFailure` | 失败应重连 | 连接断开 | 自动重连 | - |
| `ShouldDetectVersionChange` | 应检测版本变化 | 版本号增加 | 触发重载 | - |
---
## P2 - 推荐覆盖(业务逻辑)
### GatewayConfigControllerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldCreateTenant` | 应创建租户 | 有效 DTO | 201 Created | `IDbContextFactory` |
| `ShouldRejectDuplicateTenant` | 应拒绝重复租户 | 已存在的 TenantCode | 400 BadRequest | - |
| `ShouldCreateRoute` | 应创建路由 | 有效 DTO | 201 Created | - |
| `ShouldDeleteTenant` | 应删除租户 | 有效 ID | 204 NoContent | - |
| `ShouldReturn404ForMissingTenant` | 不存在租户返回 404 | 无效 ID | 404 NotFound | - |
| `ShouldReloadConfig` | 应重载配置 | POST /config/reload | 200 OK | `IRouteCache` |
### PendingServicesControllerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldListPendingServices` | 应列出待处理服务 | GET 请求 | 待处理服务列表 | `IDbContextFactory` |
| `ShouldAssignService` | 应分配服务 | 有效请求 | 服务实例创建 | - |
| `ShouldRejectInvalidCluster` | 应拒绝无效集群 | 不存在的 ClusterId | 400 BadRequest | - |
| `ShouldRejectService` | 应拒绝服务 | reject 请求 | 状态更新为 Rejected | - |
---
## 测试依赖
```xml
<!-- YarpGateway.Tests.csproj -->
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.7.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.7.0" />
</ItemGroup>
```
---
## 运行测试命令
```bash
# 运行所有测试
dotnet test
# 运行特定测试类
dotnet test --filter "FullyQualifiedName~JwtTransformMiddlewareTests"
# 生成覆盖率报告
dotnet test --collect:"XPlat Code Coverage"
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage
```
---
## 覆盖率目标
| 组件 | 目标覆盖率 | 优先级 |
|------|-----------|--------|
| JwtTransformMiddleware | 90% | P0 |
| TenantRoutingMiddleware | 85% | P0 |
| RouteCache | 80% | P1 |
| DistributedWeightedRoundRobinPolicy | 80% | P1 |
| Controllers | 70% | P2 |
| 整体项目 | 75% | - |
---
*测试计划由分析生成,建议按优先级逐步实现。*

View File

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Test Packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="xunit" Version="2.7.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<!-- Centralized from src/ -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,238 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
using FluentAssertions;
using YarpGateway.Config;
using YarpGateway.Middleware;
namespace YarpGateway.Tests.Unit.Middleware;
public class JwtTransformMiddlewareTests
{
private readonly Mock<ILogger<JwtTransformMiddleware>> _loggerMock;
private readonly JwtConfig _jwtConfig;
public JwtTransformMiddlewareTests()
{
_jwtConfig = new JwtConfig
{
Authority = "https://auth.example.com",
Audience = "yarp-gateway"
};
_loggerMock = new Mock<ILogger<JwtTransformMiddleware>>();
}
private JwtTransformMiddleware CreateMiddleware()
{
var jwtConfigOptions = Options.Create(_jwtConfig);
return new JwtTransformMiddleware(
next: Mock.Of<RequestDelegate>(),
jwtConfig: jwtConfigOptions,
logger: _loggerMock.Object
);
}
private DefaultHttpContext CreateAuthenticatedContext(string? tenantId = "tenant-1", string? userId = "user-1")
{
var context = new DefaultHttpContext();
var claims = new List<Claim>();
if (!string.IsNullOrEmpty(tenantId))
{
claims.Add(new Claim("tenant", tenantId));
}
if (!string.IsNullOrEmpty(userId))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
claims.Add(new Claim("sub", userId));
}
claims.Add(new Claim(ClaimTypes.Name, "testuser"));
claims.Add(new Claim("name", "Test User"));
claims.Add(new Claim(ClaimTypes.Role, "admin"));
claims.Add(new Claim("role", "user"));
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
context.User = principal;
return context;
}
private DefaultHttpContext CreateUnauthenticatedContext()
{
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal();
return context;
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractTenantClaim()
{
// Arrange
var context = CreateAuthenticatedContext(tenantId: "tenant-123");
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-123");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserId()
{
// Arrange
var context = CreateAuthenticatedContext(userId: "user-456");
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-User-Id"].Should().Contain("user-456");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserName()
{
// Arrange
var context = CreateAuthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-User-Name"].Should().Contain("Test User");
}
[Fact]
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractRoles()
{
// Arrange
var context = CreateAuthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Roles"].Should().Contain("admin,user");
}
[Fact]
public async Task InvokeAsync_WithUnauthenticatedUser_ShouldNotSetHeaders()
{
// Arrange
var context = CreateUnauthenticatedContext();
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
context.Request.Headers.Should().NotContainKey("X-User-Id");
}
[Fact]
public async Task InvokeAsync_WithMissingTenantClaim_ShouldLogWarning()
{
// Arrange
var context = CreateAuthenticatedContext(tenantId: null!);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - should not throw, just log warning
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
}
[Fact]
public async Task InvokeAsync_WithTenantClaimUsingTenantIdType_ShouldExtractCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var claims = new List<Claim>
{
new Claim("tenant_id", "tenant-using-id-type"),
new Claim(ClaimTypes.NameIdentifier, "user-1")
};
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-using-id-type");
}
[Fact]
public async Task InvokeAsync_ShouldRemoveExistingXHeaders_PreventHeaderInjection()
{
// Arrange
var context = CreateAuthenticatedContext();
// Simulate header injection attempt
context.Request.Headers["X-Tenant-Id"] = "injected-tenant";
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - the injected header should be removed and replaced with JWT value
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-1");
}
[Fact]
public async Task InvokeAsync_WithMultipleTenantClaims_ShouldPrioritizeTenantType()
{
// Arrange
var context = new DefaultHttpContext();
var claims = new List<Claim>
{
new Claim("tenant", "tenant-from-claim"),
new Claim("tenant_id", "tenant-id-claim"),
new Claim(ClaimTypes.NameIdentifier, "user-1")
};
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act
await middleware.InvokeAsync(context);
// Assert - should prioritize "tenant" over "tenant_id"
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-from-claim");
}
[Fact]
public async Task InvokeAsync_WithEmptyClaims_ShouldNotThrow()
{
// Arrange
var context = new DefaultHttpContext();
var identity = new ClaimsIdentity(Array.Empty<Claim>(), JwtBearerDefaults.AuthenticationScheme);
context.User = new ClaimsPrincipal(identity);
var middleware = CreateMiddleware();
// Act & Assert - should not throw
await middleware.InvokeAsync(context);
}
}

View File

@ -0,0 +1,313 @@
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<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 tenantId, string headerTenantId)
{
var context = CreateContext(headerTenantId);
var claims = new List<Claim>
{
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<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");
}
[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<string>(), It.IsAny<string>()),
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");
}
}

View File

@ -0,0 +1,303 @@
using Microsoft.Extensions.Logging;
using Moq;
using StackExchange.Redis;
using Xunit;
using FluentAssertions;
using YarpGateway.Config;
using YarpGateway.Services;
namespace YarpGateway.Tests.Unit.Services;
public class RedisConnectionManagerTests
{
private readonly Mock<IConnectionMultiplexer> _connectionMock;
private readonly Mock<IDatabase> _databaseMock;
private readonly Mock<ILogger<RedisConnectionManager>> _loggerMock;
private readonly RedisConfig _config;
public RedisConnectionManagerTests()
{
_connectionMock = new Mock<IConnectionMultiplexer>();
_databaseMock = new Mock<IDatabase>();
_connectionMock
.Setup(x => x.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(_databaseMock.Object);
_loggerMock = new Mock<ILogger<RedisConnectionManager>>();
_config = new RedisConfig
{
ConnectionString = "localhost:6379",
InstanceName = "test-instance",
Database = 0
};
}
private RedisConnectionManager CreateManager(IConnectionMultiplexer? connection = null)
{
var conn = connection ?? _connectionMock.Object;
// Use reflection to create the manager with a mock connection
var manager = new RedisConnectionManager(_config, _loggerMock.Object);
// Replace the lazy connection
var lazyConnectionField = typeof(RedisConnectionManager)
.GetField("_lazyConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var lazyConnection = new Lazy<IConnectionMultiplexer>(() => conn);
lazyConnectionField!.SetValue(manager, lazyConnection);
return manager;
}
[Fact]
public void GetConnection_ShouldReturnConnection()
{
// Arrange
var manager = CreateManager();
// Act
var connection = manager.GetConnection();
// Assert
connection.Should().BeSameAs(_connectionMock.Object);
}
[Fact]
public async Task AcquireLockAsync_WhenLockAvailable_ShouldAcquireLock()
{
// Arrange
var db = _databaseMock;
db.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var manager = CreateManager();
// Act
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
// Assert
lockObj.Should().NotBeNull();
}
[Fact]
public async Task AcquireLockAsync_WhenLockNotAvailable_ShouldRetry()
{
// Arrange
var callCount = 0;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(() =>
{
callCount++;
return callCount > 3; // Succeed after 3 retries
});
var manager = CreateManager();
// Act
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
// Assert
lockObj.Should().NotBeNull();
callCount.Should().BeGreaterThan(1);
}
[Fact]
public async Task AcquireLockAsync_WhenRetryExhausted_ShouldThrowTimeoutException()
{
// Arrange
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(false); // Always fail
var manager = CreateManager();
// Act & Assert
await FluentActions.Invoking(() => manager.AcquireLockAsync("test-key", TimeSpan.FromMilliseconds(100)))
.Should().ThrowAsync<TimeoutException>();
}
[Fact]
public async Task ExecuteInLockAsync_ShouldExecuteFunction()
{
// Arrange
var functionExecuted = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
var manager = CreateManager();
// Act
var result = await manager.ExecuteInLockAsync("test-key", () =>
{
functionExecuted = true;
return Task.FromResult("success");
});
// Assert
functionExecuted.Should().BeTrue();
result.Should().Be("success");
}
[Fact]
public async Task ExecuteInLockAsync_ShouldReleaseLockAfterExecution()
{
// Arrange
var lockReleased = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
_databaseMock
.Setup(x => x.ScriptEvaluate(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.Callback(() => lockReleased = true);
var manager = CreateManager();
// Act
await manager.ExecuteInLockAsync("test-key", () => Task.FromResult("done"));
// Assert
lockReleased.Should().BeTrue();
}
[Fact]
public async Task AcquireLockAsync_ShouldUseCorrectKeyFormat()
{
// Arrange
RedisKey? capturedKey = null;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((key, _, _, _, _, _) =>
{
capturedKey = key;
})
.Returns(Task.FromResult(true));
var manager = CreateManager();
// Act
await manager.AcquireLockAsync("my-resource");
// Assert
capturedKey.Should().NotBeNull();
capturedKey!.ToString().Should().Contain("lock:test-instance:");
capturedKey.ToString().Should().Contain("my-resource");
}
[Fact]
public async Task AcquireLockAsync_ShouldUseDefaultExpiryWhenNotProvided()
{
// Arrange
TimeSpan? capturedExpiry = null;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((_, _, expiry, _, _, _) =>
{
capturedExpiry = expiry;
})
.Returns(Task.FromResult(true));
var manager = CreateManager();
// Act
await manager.AcquireLockAsync("test-key");
// Assert
capturedExpiry.Should().NotBeNull();
capturedExpiry.Should().Be(TimeSpan.FromSeconds(10)); // Default is 10 seconds
}
[Fact]
public async Task ExecuteInLockAsync_WithException_ShouldStillReleaseLock()
{
// Arrange
var lockReleased = false;
_databaseMock
.Setup(x => x.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<bool>(),
When.NotExists,
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
_databaseMock
.Setup(x => x.ScriptEvaluate(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.Callback(() => lockReleased = true);
var manager = CreateManager();
// Act & Assert
await FluentActions.Invoking(() =>
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
.Should().ThrowAsync<InvalidOperationException>();
await FluentActions.Invoking(() =>
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
.Should().ThrowAsync<InvalidOperationException>();
// Lock should still be released
lockReleased.Should().BeTrue();
}
}

View File

@ -0,0 +1,433 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using FluentAssertions;
using YarpGateway.Data;
using YarpGateway.Models;
using YarpGateway.Services;
namespace YarpGateway.Tests.Unit.Services;
public class RouteCacheTests
{
private readonly Mock<IDbContextFactory<GatewayDbContext>> _dbContextFactoryMock;
private readonly Mock<ILogger<RouteCache>> _loggerMock;
public RouteCacheTests()
{
_dbContextFactoryMock = new Mock<IDbContextFactory<GatewayDbContext>>();
_loggerMock = new Mock<ILogger<RouteCache>>();
}
private GatewayDbContext CreateInMemoryDbContext(List<GwTenantRoute> routes)
{
var options = new DbContextOptionsBuilder<GatewayDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var context = new GatewayDbContext(options);
context.TenantRoutes.AddRange(routes);
context.SaveChanges();
return context;
}
private RouteCache CreateRouteCache(GatewayDbContext context)
{
_dbContextFactoryMock
.Setup(x => x.CreateDbContext())
.Returns(context);
return new RouteCache(
dbContextFactory: _dbContextFactoryMock.Object,
logger: _loggerMock.Object
);
}
[Fact]
public async Task InitializeAsync_ShouldLoadGlobalRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "order-service",
ClusterId = "cluster-order",
PathPattern = "/api/order/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
// Act
await routeCache.InitializeAsync();
// Assert
var result = routeCache.GetRoute("any-tenant", "user-service");
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-user");
}
[Fact]
public async Task InitializeAsync_ShouldLoadTenantRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "tenant-1",
ServiceName = "user-service",
ClusterId = "cluster-tenant-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = false,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
// Act
await routeCache.InitializeAsync();
// Assert
var result = routeCache.GetRoute("tenant-1", "user-service");
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-tenant-user");
}
[Fact]
public async Task GetRoute_WithTenantRouteAvailable_ShouldReturnTenantRoute()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "tenant-1",
ServiceName = "user-service",
ClusterId = "tenant-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = false,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "global-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("tenant-1", "user-service");
// Assert - tenant route should be prioritized
result.Should().NotBeNull();
result!.ClusterId.Should().Be("tenant-cluster");
}
[Fact]
public async Task GetRoute_WithoutTenantRoute_ShouldFallbackToGlobal()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "global-cluster",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("unknown-tenant", "user-service");
// Assert
result.Should().NotBeNull();
result!.ClusterId.Should().Be("global-cluster");
}
[Fact]
public async Task GetRoute_WithMissingRoute_ShouldReturnNull()
{
// Arrange
var routes = new List<GwTenantRoute>();
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRoute("tenant-1", "non-existent");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetRouteByPath_WithValidPath_ShouldReturnRoute()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRouteByPath("/api/user/users");
// Assert
result.Should().NotBeNull();
result!.ClusterId.Should().Be("cluster-user");
}
[Fact]
public async Task GetRouteByPath_WithMissingPath_ShouldReturnNull()
{
// Arrange
var routes = new List<GwTenantRoute>();
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act
var result = routeCache.GetRouteByPath("/unknown/path");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ReloadAsync_ShouldClearOldRoutes()
{
// Arrange
var initialRoutes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "old-service",
ClusterId = "old-cluster",
PathPattern = "/api/old/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(initialRoutes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Verify initial state
routeCache.GetRoute("any", "old-service").Should().NotBeNull();
// Modify the database (replace routes)
context.TenantRoutes.RemoveRange(context.TenantRoutes);
context.TenantRoutes.Add(new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "new-service",
ClusterId = "new-cluster",
PathPattern = "/api/new/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
});
context.SaveChanges();
// Act
await routeCache.ReloadAsync();
// Assert - old route should be gone, new route should exist
routeCache.GetRoute("any", "old-service").Should().BeNull();
routeCache.GetRoute("any", "new-service").Should().NotBeNull();
}
[Fact]
public async Task InitializeAsync_ShouldExcludeDeletedRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "active-service",
ClusterId = "cluster-1",
PathPattern = "/api/active/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "deleted-service",
ClusterId = "cluster-2",
PathPattern = "/api/deleted/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = true
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Assert
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
routeCache.GetRoute("any", "deleted-service").Should().BeNull();
}
[Fact]
public async Task InitializeAsync_ShouldExcludeInactiveRoutes()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "active-service",
ClusterId = "cluster-1",
PathPattern = "/api/active/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
},
new GwTenantRoute
{
Id = 2,
TenantCode = "",
ServiceName = "inactive-service",
ClusterId = "cluster-2",
PathPattern = "/api/inactive/**",
Priority = 1,
Status = 0, // Inactive
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Assert
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
routeCache.GetRoute("any", "inactive-service").Should().BeNull();
}
[Fact]
public async Task GetRoute_ConcurrentReads_ShouldBeThreadSafe()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new GwTenantRoute
{
Id = 1,
TenantCode = "",
ServiceName = "user-service",
ClusterId = "cluster-user",
PathPattern = "/api/user/**",
Priority = 1,
Status = 1,
IsGlobal = true,
IsDeleted = false
}
};
var context = CreateInMemoryDbContext(routes);
var routeCache = CreateRouteCache(context);
await routeCache.InitializeAsync();
// Act & Assert - multiple concurrent reads should not throw
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => routeCache.GetRoute("any", "user-service")))
.ToList();
var results = await Task.WhenAll(tasks);
// All results should be consistent
results.Should().AllSatisfy(r => r.Should().NotBeNull());
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/YarpGateway.csproj" />
</ItemGroup>
</Project>