diff --git a/.planning/codebase/SECURITY_AUDIT.md b/.planning/codebase/SECURITY_AUDIT.md new file mode 100644 index 0000000..a2db93b --- /dev/null +++ b/.planning/codebase/SECURITY_AUDIT.md @@ -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. 日志安全 +- 敏感信息脱敏 +- 限制日志访问权限 +- 使用结构化日志便于审计 + +--- + +*报告由安全审计生成,建议人工复核后纳入迭代计划。* \ No newline at end of file diff --git a/.planning/codebase/TEST_PLAN.md b/.planning/codebase/TEST_PLAN.md new file mode 100644 index 0000000..aa6c5f7 --- /dev/null +++ b/.planning/codebase/TEST_PLAN.md @@ -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` | +| `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` | +| `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 + + + + + + + + + + + +``` + +--- + +## 运行测试命令 + +```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% | - | + +--- + +*测试计划由分析生成,建议按优先级逐步实现。* \ No newline at end of file diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..7106f5c --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,7 @@ + + + true + enable + enable + + diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props new file mode 100644 index 0000000..a91cf6e --- /dev/null +++ b/tests/Directory.Packages.props @@ -0,0 +1,21 @@ + + + true + + + + + + + + + + + + + + + + + + diff --git a/tests/YarpGateway.Tests/Unit/Middleware/JwtTransformMiddlewareTests.cs b/tests/YarpGateway.Tests/Unit/Middleware/JwtTransformMiddlewareTests.cs new file mode 100644 index 0000000..1bda311 --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Middleware/JwtTransformMiddlewareTests.cs @@ -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> _loggerMock; + private readonly JwtConfig _jwtConfig; + + public JwtTransformMiddlewareTests() + { + _jwtConfig = new JwtConfig + { + Authority = "https://auth.example.com", + Audience = "yarp-gateway" + }; + _loggerMock = new Mock>(); + } + + + private JwtTransformMiddleware CreateMiddleware() + { + var jwtConfigOptions = Options.Create(_jwtConfig); + return new JwtTransformMiddleware( + next: Mock.Of(), + jwtConfig: jwtConfigOptions, + logger: _loggerMock.Object + ); + } + + + + private DefaultHttpContext CreateAuthenticatedContext(string? tenantId = "tenant-1", string? userId = "user-1") + { + var context = new DefaultHttpContext(); + + var claims = new List(); + + 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 + { + 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 + { + 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(), JwtBearerDefaults.AuthenticationScheme); + context.User = new ClaimsPrincipal(identity); + + var middleware = CreateMiddleware(); + + // Act & Assert - should not throw + await middleware.InvokeAsync(context); + } +} diff --git a/tests/YarpGateway.Tests/Unit/Middleware/TenantRoutingMiddlewareTests.cs b/tests/YarpGateway.Tests/Unit/Middleware/TenantRoutingMiddlewareTests.cs new file mode 100644 index 0000000..73ebbf2 --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Middleware/TenantRoutingMiddlewareTests.cs @@ -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 _routeCacheMock; + private readonly Mock> _loggerMock; + private readonly RequestDelegate _nextDelegate; + + public TenantRoutingMiddlewareTests() + { + _routeCacheMock = new Mock(); + _loggerMock = new Mock>(); + + // 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 + { + 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(), It.IsAny())) + .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(), It.IsAny()), + 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"); + } +} diff --git a/tests/YarpGateway.Tests/Unit/Services/RedisConnectionManagerTests.cs b/tests/YarpGateway.Tests/Unit/Services/RedisConnectionManagerTests.cs new file mode 100644 index 0000000..de6440d --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Services/RedisConnectionManagerTests.cs @@ -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 _connectionMock; + private readonly Mock _databaseMock; + private readonly Mock> _loggerMock; + private readonly RedisConfig _config; + + public RedisConnectionManagerTests() + { + _connectionMock = new Mock(); + _databaseMock = new Mock(); + + _connectionMock + .Setup(x => x.GetDatabase(It.IsAny(), It.IsAny())) + .Returns(_databaseMock.Object); + + _loggerMock = new Mock>(); + + _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(() => 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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .ReturnsAsync(false); // Always fail + + var manager = CreateManager(); + + // Act & Assert + await FluentActions.Invoking(() => manager.AcquireLockAsync("test-key", TimeSpan.FromMilliseconds(100))) + .Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteInLockAsync_ShouldExecuteFunction() + { + // Arrange + var functionExecuted = false; + + _databaseMock + .Setup(x => x.StringSetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .ReturnsAsync(true); + + _databaseMock + .Setup(x => x.ScriptEvaluate( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .Callback((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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .Callback((_, _, 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(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + When.NotExists, + It.IsAny())) + .ReturnsAsync(true); + + _databaseMock + .Setup(x => x.ScriptEvaluate( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => lockReleased = true); + + var manager = CreateManager(); + + // Act & Assert + await FluentActions.Invoking(() => + manager.ExecuteInLockAsync("test-key", () => throw new InvalidOperationException("Test"))) + .Should().ThrowAsync(); + await FluentActions.Invoking(() => + manager.ExecuteInLockAsync("test-key", () => throw new InvalidOperationException("Test"))) + .Should().ThrowAsync(); + // Lock should still be released + lockReleased.Should().BeTrue(); + } +} diff --git a/tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs b/tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs new file mode 100644 index 0000000..021e0df --- /dev/null +++ b/tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs @@ -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> _dbContextFactoryMock; + private readonly Mock> _loggerMock; + + public RouteCacheTests() + { + _dbContextFactoryMock = new Mock>(); + _loggerMock = new Mock>(); + } + + private GatewayDbContext CreateInMemoryDbContext(List routes) + { + var options = new DbContextOptionsBuilder() + .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 + { + 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 + { + 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 + { + 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 + { + 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(); + + 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 + { + 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(); + + 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 + { + 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 + { + 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 + { + 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 + { + 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()); + } +} diff --git a/tests/YarpGateway.Tests/YarpGateway.Tests.csproj b/tests/YarpGateway.Tests/YarpGateway.Tests.csproj new file mode 100644 index 0000000..efc643c --- /dev/null +++ b/tests/YarpGateway.Tests/YarpGateway.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +