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:
parent
5755b41664
commit
52f4b7616e
368
.planning/codebase/SECURITY_AUDIT.md
Normal file
368
.planning/codebase/SECURITY_AUDIT.md
Normal 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. 日志安全
|
||||||
|
- 敏感信息脱敏
|
||||||
|
- 限制日志访问权限
|
||||||
|
- 使用结构化日志便于审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告由安全审计生成,建议人工复核后纳入迭代计划。*
|
||||||
180
.planning/codebase/TEST_PLAN.md
Normal file
180
.planning/codebase/TEST_PLAN.md
Normal 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% | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*测试计划由分析生成,建议按优先级逐步实现。*
|
||||||
7
tests/Directory.Build.props
Normal file
7
tests/Directory.Build.props
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
21
tests/Directory.Packages.props
Normal file
21
tests/Directory.Packages.props
Normal 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>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
433
tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs
Normal file
433
tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/YarpGateway.Tests/YarpGateway.Tests.csproj
Normal file
27
tests/YarpGateway.Tests/YarpGateway.Tests.csproj
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user