diff --git a/.github/actions/setup-backend/action.yml b/.github/actions/setup-backend/action.yml
index b9ddb2845..caa454198 100644
--- a/.github/actions/setup-backend/action.yml
+++ b/.github/actions/setup-backend/action.yml
@@ -28,7 +28,7 @@ runs:
- name: Restore dependencies
shell: bash
- run: dotnet restore ${{ inputs.solution-path }}
+ run: dotnet restore ${{ inputs.solution-path }} --force-evaluate
- name: Install Tools
shell: bash
diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml
index 69ffdfd9c..c8bf5d096 100644
--- a/.github/workflows/ci-backend.yml
+++ b/.github/workflows/ci-backend.yml
@@ -6,6 +6,7 @@ on:
paths:
- 'src/Modules/**'
- 'src/Bootstrapper/**'
+ - 'src/Aspire/**'
- 'src/Shared/**'
- 'src/Contracts/**'
- 'tests/**'
@@ -20,6 +21,7 @@ on:
paths:
- 'src/Modules/**'
- 'src/Bootstrapper/**'
+ - 'src/Aspire/**'
- 'src/Shared/**'
- 'src/Contracts/**'
- 'tests/**'
@@ -181,6 +183,7 @@ jobs:
"src/Modules/Bookings/Tests/MeAjudaAi.Modules.Bookings.Tests.csproj"
"tests/MeAjudaAi.Shared.Tests/MeAjudaAi.Shared.Tests.csproj"
"tests/MeAjudaAi.ApiService.Tests/MeAjudaAi.ApiService.Tests.csproj"
+ "tests/MeAjudaAi.Gateway.Tests/MeAjudaAi.Gateway.Tests.csproj"
"tests/MeAjudaAi.Architecture.Tests/MeAjudaAi.Architecture.Tests.csproj"
)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index fe27120e3..73d92a9d4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,7 @@
+
@@ -177,7 +178,6 @@
-
-
+
\ No newline at end of file
diff --git a/MeAjudaAi.slnx b/MeAjudaAi.slnx
index 3311582c7..ba21c9728 100644
--- a/MeAjudaAi.slnx
+++ b/MeAjudaAi.slnx
@@ -15,6 +15,7 @@
+
@@ -192,6 +193,7 @@
+
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0a59ac150..89a4facd0 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -22,6 +22,14 @@ Este é o planejamento estratégico unificado da plataforma MeAjudaAi.
* **API Client Collections**: Adição de endpoints SSE para streaming de eventos (`/bookings/{id}/events`, `/providers/{id}/verification-events`), correções de paths em AllowedCitiesAdmin, padronização de variáveis (`{{bookingId}}`), remoção de tools/api-collections e referências a Postman nos docs.
* **Código e Testes**: Extração de constantes de mensagens SSE em BookingRealtimeEventsHandler, pré-compilação de Regex em BusinessMetricsMiddleware, expansão de casos de teste para headers de localização malformados.
+## 🚀 Sprint 13.2: Edge Infrastructure & API Gateway (Planejado)
+
+* **Implementação do YARP Gateway**: Criação do projeto `MeAjudaAi.Gateway` como ponto único de entrada para todos os frontends (Admin, Mobile, Web).
+* **BFF (Backend for Frontend)**: Configuração de rotas segregadas e políticas de CORS/Rate Limiting específicas para cada perfil de acesso.
+* **Security Hardening**: Centralização de validação de tokens JWT/Keycloak e sanitização de headers globais no Gateway.
+* **Service Discovery**: Integração com .NET Aspire para roteamento dinâmico para o ApiService.
+* **Resiliência**: Configuração de retentativas (Retries) e Circuit Breaker para endpoints críticos de integração.
+
---
## 🔮 Roadmaps Futuros (MVP Launch & Além)
diff --git a/src/Aspire/MeAjudaAi.AppHost/MeAjudaAi.AppHost.csproj b/src/Aspire/MeAjudaAi.AppHost/MeAjudaAi.AppHost.csproj
index 0cd042610..6ec727594 100644
--- a/src/Aspire/MeAjudaAi.AppHost/MeAjudaAi.AppHost.csproj
+++ b/src/Aspire/MeAjudaAi.AppHost/MeAjudaAi.AppHost.csproj
@@ -35,6 +35,7 @@
+
diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs
index 5afbe4fed..33d6ed2cc 100644
--- a/src/Aspire/MeAjudaAi.AppHost/Program.cs
+++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs
@@ -210,6 +210,11 @@ void AddSocialProviderEnv(string providerName, string clientIdKey, string client
.WaitFor(keycloakBootstrap)
.WithEnvironment("ASPNETCORE_ENVIRONMENT", EnvironmentHelpers.GetEnvironmentName(builder));
+ var gateway = builder.AddProject("gateway")
+ .WithReference(apiService)
+ .WithExternalHttpEndpoints()
+ .WaitFor(apiService);
+
// Admin Portal (Next.js 15 React)
var adminWebPath = Path.Combine(builder.AppHostDirectory, "..", "..", "..", "src", "Web", "MeAjudaAi.Web.Admin");
if (!Directory.Exists(adminWebPath))
@@ -220,8 +225,8 @@ void AddSocialProviderEnv(string providerName, string clientIdKey, string client
var adminPortal = builder.AddJavaScriptApp("admin-portal", adminWebPath)
.WithHttpEndpoint(port: 3002, env: "PORT")
.WithExternalHttpEndpoints()
- .WithEnvironment("NEXT_PUBLIC_API_URL", apiService.GetEndpoint("http"))
- .WaitFor(apiService)
+ .WithEnvironment("NEXT_PUBLIC_API_URL", gateway.GetEndpoint("http"))
+ .WaitFor(gateway)
.WaitFor(keycloak.Keycloak)
.WaitFor(keycloakBootstrap);
@@ -235,8 +240,8 @@ void AddSocialProviderEnv(string providerName, string clientIdKey, string client
var customerWeb = builder.AddJavaScriptApp("customer-web", customerWebPath)
.WithHttpEndpoint(port: 3000, env: "PORT")
.WithExternalHttpEndpoints()
- .WithEnvironment("NEXT_PUBLIC_API_URL", apiService.GetEndpoint("http"))
- .WaitFor(apiService)
+ .WithEnvironment("NEXT_PUBLIC_API_URL", gateway.GetEndpoint("http"))
+ .WaitFor(gateway)
.WaitFor(keycloakBootstrap);
// Nota: AddJavaScriptApp usa "dev" script por padrão em desenvolvimento
// e "build" script em produção. Ver package.json para scripts configurados.
@@ -251,8 +256,8 @@ void AddSocialProviderEnv(string providerName, string clientIdKey, string client
var providerWeb = builder.AddJavaScriptApp("provider-web", providerWebPath)
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints()
- .WithEnvironment("NEXT_PUBLIC_API_URL", apiService.GetEndpoint("http"))
- .WaitFor(apiService)
+ .WithEnvironment("NEXT_PUBLIC_API_URL", gateway.GetEndpoint("http"))
+ .WaitFor(gateway)
.WaitFor(keycloakBootstrap);
// Pass resolved endpoints to Keycloak options for bootstrap
@@ -275,7 +280,7 @@ private static void ConfigureProductionEnvironment(IDistributedApplicationBuilde
var keycloak = builder.AddMeAjudaAiKeycloakProduction();
- builder.AddProject("apiservice")
+ var apiService = builder.AddProject("apiservice")
.WithReference(postgresql.MainDatabase, "DefaultConnection")
.WithReference(redis)
.WaitFor(postgresql.MainDatabase)
@@ -286,5 +291,10 @@ private static void ConfigureProductionEnvironment(IDistributedApplicationBuilde
.WaitFor(keycloak.Keycloak)
.WaitFor(keycloakBootstrap)
.WithEnvironment("ASPNETCORE_ENVIRONMENT", EnvironmentHelpers.GetEnvironmentName(builder));
+
+ var gateway = builder.AddProject("gateway")
+ .WithReference(apiService)
+ .WithExternalHttpEndpoints()
+ .WaitFor(apiService);
}
}
diff --git a/src/Aspire/MeAjudaAi.AppHost/appsettings.json b/src/Aspire/MeAjudaAi.AppHost/appsettings.json
index 4542fda45..215b52c5c 100644
--- a/src/Aspire/MeAjudaAi.AppHost/appsettings.json
+++ b/src/Aspire/MeAjudaAi.AppHost/appsettings.json
@@ -14,8 +14,5 @@
"EndpointUrls": "https://localhost:15889"
}
}
- },
- "FeatureManagement": {
- "GeographicRestriction": false
}
}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs
index a1259073f..7d9bf5c09 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs
@@ -33,8 +33,7 @@ public static IEndpointRouteBuilder MapProviderRegistrationEndpoints(this IEndpo
"e a entidade Provider com Tier=Standard. Endpoint público, sem autenticação.")
.Produces>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
- .AllowAnonymous()
- .RequireRateLimiting(RateLimitPolicies.ProviderRegistration);
+ .AllowAnonymous();
return endpoints;
}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/MiddlewareExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/MiddlewareExtensions.cs
index 1d5a78d05..648dd0920 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/MiddlewareExtensions.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/MiddlewareExtensions.cs
@@ -11,9 +11,6 @@ public static IApplicationBuilder UseApiMiddlewares(this IApplicationBuilder app
// Cabeçalhos de segurança (no início do pipeline)
app.UseMiddleware();
- // Limitação de taxa (rate limiting)
- app.UseMiddleware();
-
return app;
}
}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs
index bb1a23794..44224e546 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs
@@ -11,10 +11,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
-using System.Threading.RateLimiting;
namespace MeAjudaAi.ApiService.Extensions;
@@ -24,293 +21,6 @@ namespace MeAjudaAi.ApiService.Extensions;
[ExcludeFromCodeCoverage]
public static class SecurityExtensions
{
- ///
- /// Valida todas as configurações relacionadas à segurança para evitar erros em produção.
- ///
- /// Configuração da aplicação
- /// Ambiente de hospedagem
- /// Lançada quando a configuração de segurança é inválida
- public static void ValidateSecurityConfiguration(IConfiguration configuration, IWebHostEnvironment environment)
- {
- ArgumentNullException.ThrowIfNull(configuration);
- ArgumentNullException.ThrowIfNull(environment);
-
- var errors = new List();
-
- // Validações de sanidade básica de Rate Limiting (configuração inválida em qualquer ambiente)
- try
- {
- var rateLimitSection = configuration.GetSection("AdvancedRateLimit");
- if (rateLimitSection.Exists())
- {
- var anonymousLimits = rateLimitSection.GetSection("Anonymous");
- var authenticatedLimits = rateLimitSection.GetSection("Authenticated");
-
- if (anonymousLimits.Exists())
- {
- var anonMinute = anonymousLimits.GetValue("RequestsPerMinute");
- var anonHour = anonymousLimits.GetValue("RequestsPerHour");
-
- if (anonMinute is null)
- errors.Add("Anonymous 'RequestsPerMinute' is missing");
- else if (anonMinute <= 0)
- errors.Add("Anonymous 'RequestsPerMinute' must be positive");
-
- if (anonHour is null)
- errors.Add("Anonymous 'RequestsPerHour' is missing");
- else if (anonHour <= 0)
- errors.Add("Anonymous 'RequestsPerHour' must be positive");
- }
-
- if (authenticatedLimits.Exists())
- {
- var authMinute = authenticatedLimits.GetValue("RequestsPerMinute");
- var authHour = authenticatedLimits.GetValue("RequestsPerHour");
-
- if (authMinute is null)
- errors.Add("Authenticated 'RequestsPerMinute' is missing");
- else if (authMinute <= 0)
- errors.Add("Authenticated 'RequestsPerMinute' must be positive");
-
- if (authHour is null)
- errors.Add("Authenticated 'RequestsPerHour' is missing");
- else if (authHour <= 0)
- errors.Add("Authenticated 'RequestsPerHour' must be positive");
- }
- }
- }
- catch (InvalidOperationException ex)
- {
- errors.Add($"Rate limiting configuration error: {ex.Message}");
- }
- catch (ArgumentException ex)
- {
- errors.Add($"Rate limiting configuration error: {ex.Message}");
- }
-
- // Lança erros de sanidade básica antes de bypassar validações de segurança
- if (errors.Count > 0)
- {
- var errorMessage = "Security configuration validation failed:\n" + string.Join("\n", errors.Select(e => $"- {e}"));
- throw new InvalidOperationException(errorMessage);
- }
-
- // Bypassa validações de segurança explicitamente em Testing (workaround para prevenir crash do Swashbuckle CLI
- // durante extração na pipeline CI que tenta carregar o container sem credenciais verdadeiras)
- var isTesting = MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment);
-
- if (isTesting)
- return;
-
- // Valida configuração de CORS
- try
- {
- var corsOptions = configuration.GetSection(CorsOptions.SectionName).Get() ?? new CorsOptions();
- corsOptions.Validate();
-
- // Validações adicionais específicas para produção
- if (environment.IsProduction())
- {
- if (corsOptions.AllowedOrigins.Contains("*"))
- errors.Add("Wildcard CORS origin (*) is not allowed in production environment");
-
- if (corsOptions.AllowedOrigins.Any(o => o.StartsWith("http://", StringComparison.OrdinalIgnoreCase)))
- errors.Add("HTTP origins are not recommended in production - use HTTPS");
-
- if (corsOptions.AllowCredentials && corsOptions.AllowedOrigins.Count > 5)
- errors.Add("Too many allowed origins with credentials enabled increases security risk");
- }
- }
- catch (InvalidOperationException ex)
- {
- errors.Add($"CORS configuration error: {ex.Message}");
- }
- catch (ArgumentException ex)
- {
- errors.Add($"CORS configuration error: {ex.Message}");
- }
-
- // Valida configuração do Keycloak
- try
- {
- var keycloakOptions = configuration.GetSection(KeycloakOptions.SectionName).Get() ?? new KeycloakOptions();
- ValidateKeycloakOptions(keycloakOptions);
-
- // Validações adicionais específicas para produção
- if (environment.IsProduction())
- {
- if (!keycloakOptions.RequireHttpsMetadata)
- errors.Add("RequireHttpsMetadata must be true in production environment");
-
- if (keycloakOptions.BaseUrl?.StartsWith("http://", StringComparison.OrdinalIgnoreCase) == true)
- errors.Add("Keycloak BaseUrl must use HTTPS in production environment");
-
- if (keycloakOptions.ClockSkew.TotalMinutes > 5)
- errors.Add("Keycloak ClockSkew should be minimal (≤5 minutes) in production for higher security");
- }
- }
- catch (InvalidOperationException ex)
- {
- errors.Add($"Keycloak configuration error: {ex.Message}");
- }
- catch (ArgumentException ex)
- {
- errors.Add($"Keycloak configuration error: {ex.Message}");
- }
-
- // Valida limites de Rate Limiting em produção (limites negativos já validados acima)
- if (environment.IsProduction())
- {
- try
- {
- var rateLimitSection = configuration.GetSection("AdvancedRateLimit");
- if (rateLimitSection.Exists())
- {
- var anonymousLimits = rateLimitSection.GetSection("Anonymous");
- if (anonymousLimits.Exists())
- {
- var anonMinute = anonymousLimits.GetValue("RequestsPerMinute");
- if (anonMinute > 100)
- errors.Add("Anonymous request limits should be conservative in production (≤100 req/min)");
- }
- }
- }
- catch (InvalidOperationException ex)
- {
- errors.Add($"Rate limiting configuration error: {ex.Message}");
- }
- catch (ArgumentException ex)
- {
- errors.Add($"Rate limiting configuration error: {ex.Message}");
- }
- }
-
- // Valida redirecionamento HTTPS em produção
- if (environment.IsProduction())
- {
- var httpsRedirection = configuration.GetValue("HttpsRedirection:Enabled");
- if (httpsRedirection == false)
- errors.Add("HTTPS redirection must be enabled in production environment");
- }
-
- // Valida AllowedHosts
- var allowedHosts = configuration.GetValue("AllowedHosts");
- if (environment.IsProduction() && allowedHosts == "*")
- errors.Add("AllowedHosts must be restricted to specific domains in production (not '*')");
-
- // Lança erros agregados se houver
- if (errors.Count > 0)
- {
- var errorMessage = "Security configuration validation failed:\n" + string.Join("\n", errors.Select(e => $"- {e}"));
- throw new InvalidOperationException(errorMessage);
- }
- }
-
- public static IServiceCollection AddCorsPolicy(
- this IServiceCollection services,
- IConfiguration configuration,
- IWebHostEnvironment environment)
- {
- ArgumentNullException.ThrowIfNull(services);
- ArgumentNullException.ThrowIfNull(configuration);
- ArgumentNullException.ThrowIfNull(environment);
- // Registra opções de CORS usando AddOptions<>()
- var optionsBuilder = services.AddOptions()
- .Configure((opts, config) =>
- {
- config.GetSection(CorsOptions.SectionName).Bind(opts);
- });
-
- var isTesting = MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment);
-
- if (!isTesting)
- {
- optionsBuilder.ValidateOnStart();
- }
-
- // Obtém opções de CORS para uso imediato na configuração da política
- var corsOptions = configuration.GetSection(CorsOptions.SectionName).Get() ?? new CorsOptions();
-
- if (!isTesting)
- {
- corsOptions.Validate();
- }
-
- services.AddCors(options =>
- {
- options.AddPolicy("DefaultPolicy", policy =>
- {
- // Configura origens permitidas
- if (corsOptions.AllowedOrigins.Contains("*"))
- {
- // Permite coringa em desenvolvimento ou ambiente de bypass (testes/CI)
- if (environment.IsDevelopment() ||
- MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment))
- {
- // AllowAnyOrigin() é incompatível com AllowCredentials()
- if (corsOptions.AllowCredentials)
- {
- // Usa SetIsOriginAllowed para permitir qualquer origem com credenciais
- policy.SetIsOriginAllowed(_ => true);
- }
- else
- {
- policy.AllowAnyOrigin();
- }
- }
- else
- {
- throw new InvalidOperationException("Wildcard CORS origin (*) is not allowed in production environments for security reasons.");
- }
- }
- else
- {
- policy.WithOrigins([.. corsOptions.AllowedOrigins]);
- }
-
- // Configura métodos permitidos
- if (corsOptions.AllowedMethods.Contains("*"))
- {
- policy.AllowAnyMethod();
- }
- else
- {
- policy.WithMethods([.. corsOptions.AllowedMethods]);
- }
-
- // Configura cabeçalhos permitidos
- if (corsOptions.AllowedHeaders.Contains("*"))
- {
- policy.AllowAnyHeader();
- }
- else
- {
- // Garante que X-XSRF-TOKEN seja incluído nos headers permitidos para o preflight
- var headers = corsOptions.AllowedHeaders.ToList();
- if (!headers.Contains("X-XSRF-TOKEN", StringComparer.OrdinalIgnoreCase))
- {
- headers.Add("X-XSRF-TOKEN");
- }
- policy.WithHeaders([.. headers]);
- }
-
- // Configura credenciais (apenas se explicitamente habilitado)
- if (corsOptions.AllowCredentials)
- {
- policy.AllowCredentials();
- }
-
- // Define tempo máximo de cache do preflight
- policy.SetPreflightMaxAge(TimeSpan.FromSeconds(corsOptions.PreflightMaxAge));
-
- // Expor header do token de antiforgery para clientes SPA
- policy.WithExposedHeaders("X-XSRF-TOKEN");
- });
- });
-
- return services;
- }
-
///
/// Configura autenticação baseada no ambiente (Keycloak para produção, teste simples para desenvolvimento)
///
@@ -536,73 +246,6 @@ clientObj is IDictionary clientDict &&
return roleClaims;
}
- ///
- /// Configura políticas de rate limiting customizadas
- ///
- public static IServiceCollection AddCustomRateLimiting(this IServiceCollection services, IConfiguration configuration)
- {
- services.AddRateLimiter(options =>
- {
- // Política para endpoints públicos anonimizados
- options.AddFixedWindowLimiter(RateLimitPolicies.Public, opt =>
- {
- opt.Window = TimeSpan.FromMinutes(1);
- opt.PermitLimit = configuration.GetValue("RateLimit:DefaultRequestsPerMinute", 60);
- opt.QueueLimit = 10;
- opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
- });
-
- // Política para registro de clientes (restritiva para evitar spam de contas)
- options.AddPolicy(RateLimitPolicies.Registration, context =>
- RateLimitPartition.GetFixedWindowLimiter(
- partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id,
- factory: _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = configuration.GetValue("RateLimit:AuthRequestsPerMinute", 5),
- Window = TimeSpan.FromMinutes(1),
- QueueLimit = 2,
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst
- }));
-
- // Política específica para registro de prestadores (mais restritiva para evitar spam)
- options.AddPolicy(RateLimitPolicies.ProviderRegistration, context =>
- RateLimitPartition.GetFixedWindowLimiter(
- partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id,
- factory: _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = configuration.GetValue("RateLimit:ProviderRequestsPerMinute", 5),
- Window = TimeSpan.FromMinutes(1),
- QueueLimit = 2,
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst
- }));
-
- options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
-
- // Política Global Dinâmica (IP vs Usuário Autenticado)
- options.GlobalLimiter = PartitionedRateLimiter.Create(context =>
- {
- var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
- var key = isAuthenticated
- ? context.User.FindFirst("sub")?.Value ?? context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id ?? "authenticated-anonymous"
- : context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id ?? "test-client";
-
- var permitLimit = isAuthenticated
- ? configuration.GetValue("AdvancedRateLimit:Authenticated:RequestsPerMinute", 120)
- : configuration.GetValue("AdvancedRateLimit:Anonymous:RequestsPerMinute", 30);
-
- return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
- {
- AutoReplenishment = true,
- PermitLimit = permitLimit,
- QueueLimit = 0,
- Window = TimeSpan.FromMinutes(1)
- });
- });
- });
-
- return services;
- }
-
public static IServiceCollection AddCustomAntiforgery(this IServiceCollection services)
{
services.AddAntiforgery(options =>
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs
index faf5c55ef..1852995f3 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@
using MeAjudaAi.ApiService.Services.Authentication;
using MeAjudaAi.Shared.Authorization.Middleware;
using MeAjudaAi.Shared.Logging;
+using MeAjudaAi.Shared.Middleware;
using MeAjudaAi.Shared.Monitoring;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -21,46 +22,19 @@ public static IServiceCollection AddApiServices(
IConfiguration configuration,
IWebHostEnvironment environment)
{
- // Valida a configuração de segurança logo no início do startup
- SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
-
// Detecta se estamos em ambiente de teste (integração ou E2E)
var isTestEnvironment = string.Equals(Environment.GetEnvironmentVariable("INTEGRATION_TESTS"), "true", StringComparison.OrdinalIgnoreCase) ||
environment.IsEnvironment("Testing");
- // Registro da configuração de Rate Limit com validação usando Options pattern
- // Suporte tanto para nova seção "AdvancedRateLimit" quanto para legado "RateLimit"
- var optionsBuilder = services.AddOptions()
- .BindConfiguration(RateLimitOptions.SectionName) // "AdvancedRateLimit"
- .BindConfiguration("RateLimit") // fallback para configuração legada
- .ValidateDataAnnotations(); // Valida atributos [Required] etc.
-
- // Apenas valida na inicialização se NÃO estiver em ambiente de teste
-
- if (!isTestEnvironment)
- {
- optionsBuilder.ValidateOnStart() // Valida na inicialização da aplicação
- .Validate(options =>
- {
- // Validações customizadas para a configuração avançada
- if (options.Anonymous.RequestsPerMinute <= 0 || options.Anonymous.RequestsPerHour <= 0 || options.Anonymous.RequestsPerDay <= 0)
- return false;
- if (options.Authenticated.RequestsPerMinute <= 0 || options.Authenticated.RequestsPerHour <= 0 || options.Authenticated.RequestsPerDay <= 0)
- return false;
- if (options.General.WindowInSeconds <= 0)
- return false;
- if (options.General.EnableIpWhitelist && (options.General.WhitelistedIps == null || options.General.WhitelistedIps.Count == 0))
- return false;
- return true;
- }, "Rate limit configuration is invalid. All limits must be greater than zero.");
- }
-
services.AddDocumentation();
services.AddApiVersioning(); // Adiciona versionamento de API
- services.AddCorsPolicy(configuration, environment);
services.AddCustomAntiforgery();
services.AddMemoryCache();
+ // Configuração de GeographicRestriction (vincula as opções do appsettings.json)
+ services.Configure(
+ configuration.GetSection(GeographicRestrictionOptions.SectionName));
+
// Configura ForwardedHeaders para suporte a proxy reverso (load balancers, nginx, etc.)
services.Configure(options =>
{
@@ -76,10 +50,6 @@ public static IServiceCollection AddApiServices(
// options.KnownIPNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
});
- // Configurar Geographic Restriction
- services.Configure(
- configuration.GetSection("GeographicRestriction"));
-
// Configuração de autenticação baseada no ambiente
if (!isTestEnvironment)
{
@@ -153,9 +123,6 @@ public static WebApplication UseApiServices(
app.UseResponseCompression();
app.UseResponseCaching();
- // Geographic Restriction ANTES de qualquer roteamento
- app.UseMiddleware();
-
// Middleware de arquivos estáticos com cache
app.UseMiddleware();
app.UseStaticFiles();
@@ -165,13 +132,14 @@ public static WebApplication UseApiServices(
app.UseApiMiddlewares();
+ app.UseMiddleware();
+
// Documentação apenas em desenvolvimento e testes
if (environment.IsDevelopment() || environment.IsEnvironment("Testing"))
{
app.UseDocumentation();
}
- app.UseCors("DefaultPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj
index f27edaa94..3e6fd3286 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs
index 3be920f58..cc8109cd9 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs
@@ -56,7 +56,7 @@ private static string BuildCspPolicy(IWebHostEnvironment environment, IConfigura
else
{
// Em produção, usar configurações
- var keycloakAuthority = configuration["Keycloak:Authority"] ?? "";
+ var keycloakAuthority = GetKeycloakAuthority(configuration);
var apiBaseUrl = configuration["ApiBaseUrl"] ?? "";
var websocketUrl = configuration["WebSocketUrl"] ?? "";
@@ -119,6 +119,20 @@ private static string BuildCspPolicy(IWebHostEnvironment environment, IConfigura
return finalPolicy;
}
+
+ private static string GetKeycloakAuthority(IConfiguration configuration)
+ {
+ var authority = configuration["Keycloak:Authority"];
+ if (!string.IsNullOrWhiteSpace(authority))
+ return authority;
+
+ var baseUrl = configuration["Keycloak:BaseUrl"];
+ var realm = configuration["Keycloak:Realm"];
+ if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(realm))
+ return $"{baseUrl.TrimEnd('/')}/realms/{realm}";
+
+ return "";
+ }
}
///
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/GeographicRestrictionMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/GeographicRestrictionMiddleware.cs
deleted file mode 100644
index 99b756565..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/GeographicRestrictionMiddleware.cs
+++ /dev/null
@@ -1,301 +0,0 @@
-using System.Text.Json;
-using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.Shared.Utilities.Constants;
-using MeAjudaAi.Shared.Geolocation;
-using MeAjudaAi.Contracts.Models;
-using Microsoft.Extensions.Options;
-using Microsoft.FeatureManagement;
-
-namespace MeAjudaAi.ApiService.Middlewares;
-
-///
-/// Middleware para restringir acesso baseado em localização geográfica.
-/// Bloqueia requisições de cidades/estados não permitidos (compliance legal).
-/// Usa Microsoft.FeatureManagement para controle dinâmico via Azure App Configuration.
-///
-public class GeographicRestrictionMiddleware(
- RequestDelegate next,
- ILogger logger,
- IOptionsMonitor options,
- IFeatureManager featureManager)
-{
- public async Task InvokeAsync(HttpContext context, IGeographicValidationService? geographicValidationService = null)
- {
- // Verificar se feature está habilitada (Microsoft.FeatureManagement)
- var isFeatureEnabled = await featureManager.IsEnabledAsync(FeatureFlags.GeographicRestriction);
-
- // Skip se desabilitado via feature flag
- if (!isFeatureEnabled)
- {
- await next(context);
- return;
- }
-
- // Skip health checks e endpoints internos
- var path = context.Request.Path.Value ?? string.Empty;
- if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase) ||
- path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
- path.StartsWith("/_framework", StringComparison.OrdinalIgnoreCase))
- {
- await next(context);
- return;
- }
-
- // Extrair localização do header X-User-Location ou IP
- var (city, state) = ExtractLocation(context);
-
- // Validar se cidade/estado está permitido
- var isAllowed = await IsLocationAllowedAsync(city, state, geographicValidationService, context.RequestAborted);
- if (!isAllowed)
- {
- logger.LogWarning(
- "Geographic restriction: Request blocked from {City}/{State}. IP: {IpAddress}",
- city ?? "Unknown",
- state ?? "Unknown",
- context.Connection.RemoteIpAddress);
-
- context.Response.StatusCode = 451; // Unavailable For Legal Reasons
- context.Response.ContentType = "application/json";
-
- var allowedRegions = GetAllowedRegionsDescription();
- var template = options.CurrentValue.BlockedMessage ?? "Access from your region is not allowed. Allowed regions: {allowedRegions}.";
- var message = template.Replace("{allowedRegions}", allowedRegions);
-
- // Converte entradas configuradas "City|State" (ou nomes simples de cidade) para objetos AllowedCity
- var allowedCitiesResponse = options.CurrentValue.AllowedCities?
- .Select(raw =>
- {
- var parts = raw.Split('|');
- var name = parts.Length > 0 ? parts[0].Trim() : raw;
- var state = parts.Length > 1 ? parts[1].Trim() : string.Empty;
-
- return new AllowedCity
- {
- Name = name,
- State = state,
- IbgeCode = null
- };
- });
-
- var errorResponse = new GeographicRestrictionErrorResponse(
- message: message,
- userLocation: new UserLocation { City = city, State = state },
- allowedCities: allowedCitiesResponse,
- allowedStates: options.CurrentValue.AllowedStates);
-
- await context.Response.WriteAsync(
- JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- WriteIndented = true
- }));
-
- return;
- }
-
- // Localização permitida - continuar pipeline
- await next(context);
- }
-
- private static (string? City, string? State) ExtractLocation(HttpContext context)
- {
- // Prioridade 1: Header X-User-Location (formato: "City|State")
- if (context.Request.Headers.TryGetValue("X-User-Location", out var locationHeader))
- {
- var headerSpan = locationHeader.ToString().AsSpan();
- var separatorIndex = headerSpan.IndexOf('|');
-
- if (separatorIndex >= 0)
- {
- var remainder = headerSpan[(separatorIndex + 1)..];
- if (remainder.IndexOf('|') >= 0)
- {
- // Header malformado, contém mais de um separador.
- // Retorna string vazia em vez de null para sinalizar "malformado, mas presente".
- return (string.Empty, string.Empty);
- }
-
- var locationCity = headerSpan[..separatorIndex].Trim().ToString();
- var locationState = remainder.Trim().ToString();
-
- // Rejeitar entradas malformadas onde city ou state estão vazios ou whitespace
- // Exemplos rejeitados: "City|", "|State", "City| ", " |State"
- if (!string.IsNullOrWhiteSpace(locationCity) && !string.IsNullOrWhiteSpace(locationState))
- {
- return (locationCity, locationState);
- }
- }
-
- // Header presente mas malformado (ex: sem separador ou valores vazios)
- // Retorna sentinel para impedir fall-open para outros headers ou IP
- return (string.Empty, string.Empty);
- }
-
- var cityPresent = context.Request.Headers.TryGetValue("X-User-City", out var cityHeader);
- var statePresent = context.Request.Headers.TryGetValue("X-User-State", out var stateHeader);
-
- if (cityPresent || statePresent)
- {
- var city = cityPresent ? (string.IsNullOrWhiteSpace(cityHeader.ToString()) ? string.Empty : cityHeader.ToString().Trim()) : null;
- var state = statePresent ? (string.IsNullOrWhiteSpace(stateHeader.ToString()) ? string.Empty : stateHeader.ToString().Trim()) : null;
- return (city, state);
- }
-
- // TODO: Implementar GeoIP lookup baseado em IP para detectar localização automaticamente.
- // Opções: MaxMind GeoIP2, IP2Location, ou IPGeolocation API.
-
- return (null, null);
- }
-
- private async Task IsLocationAllowedAsync(string? city, string? state, IGeographicValidationService? geographicValidationService, CancellationToken cancellationToken)
- {
- // Mas se a string estiver vazia (Length == 0), significa que detectamos malformação ou header vazio
- if (city?.Length == 0 || state?.Length == 0)
- {
- logger.LogWarning("Geographic restriction: Malformed or empty location header detected, rejecting request.");
- return false;
- }
-
- if (string.IsNullOrEmpty(city) && string.IsNullOrEmpty(state))
- {
- logger.LogWarning("Geographic restriction: Could not determine user location, allowing access (fail-open)");
- return true;
- }
-
- // Estratégia 1: Validação simples (case-insensitive string matching)
- // Usada quando IBGE service não está disponível
- var simpleValidation = ValidateLocationSimple(city, state);
-
- // Estratégia 2: Validação via API IBGE (normalização + verificação precisa)
- // Só executar se o serviço estiver disponível e temos cidade
- if (geographicValidationService is not null && !string.IsNullOrEmpty(city))
- {
- try
- {
- logger.LogDebug("Validating city {City} via IBGE API", city);
-
- var ibgeValidation = await geographicValidationService.ValidateCityAsync(
- city,
- state,
- cancellationToken);
-
- // Validação IBGE tem prioridade (mais precisa)
- logger.LogInformation(
- "IBGE validation for {City}/{State}: {Result} (simple: {SimpleResult})",
- city, state ?? "N/A", ibgeValidation, simpleValidation);
-
- return ibgeValidation;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Error validating with IBGE, falling back to simple validation");
- // Fallback para validação simples em caso de erro
- }
- }
- else
- {
- if (geographicValidationService is null)
- logger.LogDebug("IBGE validation service not available, using simple validation");
- else if (string.IsNullOrEmpty(city))
- logger.LogDebug("No city provided, skipping IBGE validation");
- }
-
- // Fallback: validação simples se IBGE falhar ou não estiver disponível
- return simpleValidation;
- }
-
- private bool ValidateLocationSimple(string? city, string? state)
- {
- // Se temos cidade, validar contra lista de cidades permitidas
- // Suporta tanto formato "City|State" quanto apenas nome da cidade
- if (!string.IsNullOrEmpty(city))
- {
- if (options.CurrentValue.AllowedCities == null)
- {
- logger.LogWarning("Geographic restriction enabled but AllowedCities is null - failing open");
- return true; // Fail-open quando mal configurado
- }
-
- foreach (var allowedCity in options.CurrentValue.AllowedCities)
- {
- var citySpan = allowedCity.AsSpan();
- var separatorIndex = citySpan.IndexOf('|');
-
- if (separatorIndex < 0)
- {
- // Tratar como entrada somente de cidade (sem estado)
- var configCityOnly = citySpan.Trim().ToString();
- if (!string.IsNullOrEmpty(configCityOnly) &&
- configCityOnly.Equals(city, StringComparison.OrdinalIgnoreCase))
- {
- // Se não temos estado informado, aceitar apenas pelo nome da cidade
- if (string.IsNullOrEmpty(state))
- return true;
-
- // Com estado informado, dependerá da regra de estados permitidos
- return options.CurrentValue.AllowedStates?.Any(s =>
- s.Equals(state, StringComparison.OrdinalIgnoreCase)) == true;
- }
- continue;
- }
-
- var remainder = citySpan[(separatorIndex + 1)..];
- if (remainder.IndexOf('|') >= 0)
- {
- logger.LogWarning("Malformed configuration (too many separators): {AllowedCity}", allowedCity);
- continue;
- }
-
- var configCity = citySpan[..separatorIndex].Trim().ToString();
- var configState = remainder.Trim().ToString();
-
- // Rejeitar entradas onde city ou state estão vazios
- if (string.IsNullOrEmpty(configCity) || string.IsNullOrEmpty(configState))
- {
- logger.LogWarning("Malformed configuration (empty values): {AllowedCity}", allowedCity);
- continue;
- }
-
- // Match city (case-insensitive)
- if (configCity.Equals(city, StringComparison.OrdinalIgnoreCase))
- {
- // Se temos state, validar também
- if (!string.IsNullOrEmpty(state))
- {
- return configState.Equals(state, StringComparison.OrdinalIgnoreCase);
- }
- // Se não temos state, match apenas por cidade
- return true;
- }
- }
- return false;
- }
-
- // Se temos apenas estado (sem cidade), validar contra lista de estados permitidos
- if (!string.IsNullOrEmpty(state))
- {
- if (options.CurrentValue.AllowedStates == null)
- {
- logger.LogWarning("Geographic restriction enabled but AllowedStates is null - failing open");
- return true; // Fail-open quando mal configurado
- }
-
- return options.CurrentValue.AllowedStates.Any(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
- }
-
- return false;
- }
-
- private string GetAllowedRegionsDescription()
- {
- var cities = options.CurrentValue.AllowedCities?.Any() == true
- ? string.Join(", ", options.CurrentValue.AllowedCities)
- : "N/A";
-
- var states = options.CurrentValue.AllowedStates?.Any() == true
- ? string.Join(", ", options.CurrentValue.AllowedStates)
- : "N/A";
-
- return $"Cities: {cities} | States: {states}";
- }
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs
deleted file mode 100644
index d67c0868f..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs
+++ /dev/null
@@ -1,278 +0,0 @@
-using System.Collections.Concurrent;
-using System.Text.Json;
-using System.Text.RegularExpressions;
-using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.Shared.Serialization;
-using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
-
-namespace MeAjudaAi.ApiService.Middlewares;
-
-///
-/// Middleware de Rate Limiting com suporte a usuários autenticados.
-/// Implementa limitação de taxa de requisições com base em IP, usuário autenticado, role e endpoint.
-///
-///
-/// Configuração: Seção "AdvancedRateLimit" no appsettings.json
-/// Limites Padrão:
-///
-/// - Anônimos: 30 req/min, 300 req/hora, 1000 req/dia
-/// - Autenticados: 120 req/min, 2000 req/hora, 10000 req/dia
-/// - Por Role: Configurável via RoleLimits (ex: Admin com limites maiores)
-/// - Por Endpoint: Configurável via EndpointLimits (ex: /api/auth/* com limite menor)
-///
-/// Whitelist de IPs: Configurável para bypass (ex: load balancers, health checks)
-/// Resposta ao Exceder Limite:
-///
-/// - Status Code: 429 Too Many Requests
-/// - Header Retry-After: tempo em segundos até liberação
-/// - Body JSON com mensagem de erro e detalhes
-///
-/// Thread-Safety: Usa Interlocked.Increment para incremento atômico de contadores
-///
-public class RateLimitingMiddleware(
- RequestDelegate next,
- IMemoryCache cache,
- IOptionsMonitor options,
- ILogger logger)
-{
- ///
- /// Cache de padrões Regex compilados para performance. Limitado a 1000 entradas para prevenir memory leaks.
- /// Em configurações normais, o número de padrões de endpoint é pequeno (<100), mas esse limite
- /// previne crescimento descontrolado se padrões forem adicionados dinamicamente.
- ///
- private static readonly ConcurrentDictionary> _patternCache = new();
- private const int MaxPatternCacheSize = 1000;
- private static int _cacheFullWarningLogged;
-
- ///
- /// Classe contador simples para rate limiting.
- ///
- /// Thread-safety: O campo deve ser acessado ou modificado apenas usando operações thread-safe,
- /// como . Esta classe foi projetada para ser usada em um ambiente concorrente,
- /// e todas as modificações no devem ser realizadas atomicamente.
- ///
- ///
- private sealed class Counter
- {
- public int Value;
- public DateTime ExpiresAt;
- }
-
- public async Task InvokeAsync(HttpContext context)
- {
- var currentOptions = options.CurrentValue;
-
- // Ignora rate limiting se explicitamente desabilitado
- if (!currentOptions.General.Enabled)
- {
- await next(context);
- return;
- }
-
- var clientIp = GetClientIpAddress(context);
- var isAuthenticated = context.User.Identity?.IsAuthenticated == true;
-
- // Verifica whitelist de IPs primeiro - ignora rate limiting se IP estiver na whitelist
- if (currentOptions.General.EnableIpWhitelist &&
- currentOptions.General.WhitelistedIps.Contains(clientIp))
- {
- await next(context);
- return;
- }
-
- // Garante janela mínima de 1 segundo por segurança
- var windowSeconds = Math.Max(1, currentOptions.General.WindowInSeconds);
- var effectiveWindow = TimeSpan.FromSeconds(windowSeconds);
-
- // Determina limite efetivo usando ordem de prioridade
- var limit = GetEffectiveLimit(context, currentOptions, isAuthenticated, effectiveWindow);
-
- // Chave por usuário (quando autenticado) e método para reduzir false sharing
- var userKey = isAuthenticated
- ? (context.User.FindFirst("sub")?.Value ?? context.User.Identity?.Name ?? clientIp)
- : clientIp;
-
- // Use route template when available to prevent memory pressure from dynamic path parameters
- var endpoint = context.GetEndpoint();
- var routeEndpoint = endpoint as RouteEndpoint;
- var pathKey = routeEndpoint?.RoutePattern.RawText ?? context.Request.Path.ToString();
-
- var key = $"rate_limit:{userKey}:{context.Request.Method}:{pathKey}";
-
- var counter = cache.GetOrCreate(key, entry =>
- {
- entry.AbsoluteExpirationRelativeToNow = effectiveWindow;
- return new Counter { ExpiresAt = DateTime.UtcNow + effectiveWindow };
- })!; // GetOrCreate nunca retorna null quando factory retorna um valor
-
- var current = Interlocked.Increment(ref counter.Value);
-
- if (current > limit)
- {
- logger.LogWarning("Rate limit exceeded for client {ClientIp} on path {Path}. Limit: {Limit}, Current count: {Count}, Window: {Window}s",
- clientIp, context.Request.Path, limit, current, windowSeconds);
- await HandleRateLimitExceeded(context, counter, currentOptions.General.ErrorMessage, (int)effectiveWindow.TotalSeconds);
- return;
- }
-
- // TTL definido na criação; sem necessidade de operação redundante de cache
- var warnThreshold = (int)Math.Ceiling(limit * 0.8);
- if (current >= warnThreshold) // aproximando do limite (80%)
- {
- logger.LogInformation("Client {ClientIp} approaching rate limit on path {Path}. Current: {Count}/{Limit}, Window: {Window}s",
- clientIp, context.Request.Path, current, limit, currentOptions.General.WindowInSeconds);
- }
-
- await next(context);
- }
-
- private int GetEffectiveLimit(HttpContext context, RateLimitOptions rateLimitOptions, bool isAuthenticated, TimeSpan window)
- {
- var requestPath = context.Request.Path.Value ?? string.Empty;
-
- // 1. Verifica limites específicos de endpoint primeiro com ordenação determinística
- // Ordena por: padrões mais longos primeiro (mais específicos), depois exatos antes de wildcards
- var matchingLimit = rateLimitOptions.EndpointLimits
- .OrderByDescending(e => e.Value.Pattern.Length)
- .ThenBy(e => e.Value.Pattern.Contains('*') ? 1 : 0)
- .FirstOrDefault(endpointLimit =>
- IsPathMatch(requestPath, endpointLimit.Value.Pattern) &&
- ((isAuthenticated && endpointLimit.Value.ApplyToAuthenticated) ||
- (!isAuthenticated && endpointLimit.Value.ApplyToAnonymous)));
-
- if (matchingLimit.Value != null)
- {
- return ScaleToWindow(
- matchingLimit.Value.RequestsPerMinute,
- matchingLimit.Value.RequestsPerHour,
- 0,
- window);
- }
-
- // 2. Verifica limites específicos de role (apenas para usuários autenticados)
- if (isAuthenticated)
- {
- var userRoles = context.User.FindAll("role")?.Select(c => c.Value) ??
- context.User.FindAll("http://schemas.microsoft.com/ws/2008/06/identity/claims/role")?.Select(c => c.Value) ??
- [];
-
- // Usa o limite mais permissivo (maior) entre todas as roles do usuário
- int? maxRoleLimit = null;
- foreach (var role in userRoles)
- {
- if (rateLimitOptions.RoleLimits.TryGetValue(role, out var roleLimit))
- {
- var limit = ScaleToWindow(
- roleLimit.RequestsPerMinute,
- roleLimit.RequestsPerHour,
- roleLimit.RequestsPerDay,
- window);
-
- if (maxRoleLimit == null || limit > maxRoleLimit)
- maxRoleLimit = limit;
- }
- }
-
- if (maxRoleLimit.HasValue)
- return maxRoleLimit.Value;
- }
-
- // 3. Usa limites padrão de autenticado/anônimo como fallback
- return isAuthenticated
- ? ScaleToWindow(rateLimitOptions.Authenticated.RequestsPerMinute, rateLimitOptions.Authenticated.RequestsPerHour, rateLimitOptions.Authenticated.RequestsPerDay, window)
- : ScaleToWindow(rateLimitOptions.Anonymous.RequestsPerMinute, rateLimitOptions.Anonymous.RequestsPerHour, rateLimitOptions.Anonymous.RequestsPerDay, window);
- }
-
- private static int ScaleToWindow(int perMinute, int perHour, int perDay, TimeSpan window)
- {
- var secs = Math.Max(1, (int)window.TotalSeconds);
- var candidates = new List(3);
- if (perMinute > 0) candidates.Add(perMinute * secs / 60.0);
- if (perHour > 0) candidates.Add(perHour * secs / 3600.0);
- if (perDay > 0) candidates.Add(perDay * secs / 86400.0);
- var allowed = candidates.Count > 0 ? candidates.Min() : 0.0;
- return Math.Max(1, (int)Math.Floor(allowed));
- }
-
- private bool IsPathMatch(string requestPath, string pattern)
- {
- if (string.IsNullOrEmpty(pattern))
- return false;
-
- // Correspondência simples de wildcard - pode ser melhorado para padrões mais complexos
- if (pattern.Contains('*'))
- {
- // Primeiro verifica se o padrão já está no cache
- if (_patternCache.TryGetValue(pattern, out var existingRegex))
- {
- return existingRegex.Value.IsMatch(requestPath);
- }
-
- // Prevenir memory leak: limitar cache a MaxPatternCacheSize entradas
- if (_patternCache.Count >= MaxPatternCacheSize)
- {
- // Log warning apenas uma vez quando o limite é atingido
- if (Interlocked.CompareExchange(ref _cacheFullWarningLogged, 1, 0) == 0)
- {
- logger.LogWarning(
- "Pattern cache size limit reached ({MaxSize}). Additional patterns will be compiled on-demand without caching.",
- MaxPatternCacheSize);
- }
-
- // Compilar sem adicionar ao cache (sem RegexOptions.Compiled para evitar overhead)
- var escaped = Regex.Escape(pattern).Replace(@"\*", ".*");
- var regex = new Regex($"^{escaped}$", RegexOptions.IgnoreCase);
- return regex.IsMatch(requestPath);
- }
-
- var cachedRegex = _patternCache.GetOrAdd(pattern, p =>
- {
- return new Lazy(() =>
- {
- var escaped = Regex.Escape(p).Replace(@"\*", ".*");
- return new Regex($"^{escaped}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- });
- });
- return cachedRegex.Value.IsMatch(requestPath);
- }
-
- return string.Equals(requestPath, pattern, StringComparison.OrdinalIgnoreCase);
- }
-
- private static string GetClientIpAddress(HttpContext context)
- {
- // Usa o IP já resolvido pelo ForwardedHeadersMiddleware, que valida proxies
- // confiáveis via KnownProxies/KnownNetworks. Isso evita que clientes
- // maliciosos forjem IPs da whitelist ou rotacionem IPs falsos para
- // burlar os limites de taxa por IP. O ForwardedHeadersMiddleware deve
- // estar configurado no pipeline antes deste middleware com as
- // configurações apropriadas de KnownProxies/KnownNetworks.
- return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
- }
-
- private static async Task HandleRateLimitExceeded(HttpContext context, Counter counter, string errorMessage, int windowInSeconds)
- {
- // Calcula TTL restante da expiração do contador
- var retryAfterSeconds = Math.Max(0, (int)Math.Ceiling((counter.ExpiresAt - DateTime.UtcNow).TotalSeconds));
-
- context.Response.StatusCode = 429;
- context.Response.Headers.Append("Retry-After", retryAfterSeconds.ToString());
- context.Response.ContentType = "application/json";
-
- var errorResponse = new
- {
- Error = "RateLimitExceeded",
- Message = errorMessage,
- Details = new Dictionary
- {
- ["retryAfterSeconds"] = retryAfterSeconds,
- ["windowInSeconds"] = windowInSeconds
- }
- };
-
- var json = JsonSerializer.Serialize(errorResponse, SerializationDefaults.Api);
-
- await context.Response.WriteAsync(json);
- }
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RequestLoggingMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RequestLoggingMiddleware.cs
index cbfe8ad02..95a4447b3 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RequestLoggingMiddleware.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RequestLoggingMiddleware.cs
@@ -135,7 +135,14 @@ private static bool ShouldSkipLogging(HttpContext context)
private static string GetClientIpAddress(HttpContext context)
{
- // Considera proxies e load balancers
+ // Usar RemoteIpAddress que já foi normalizado pelo UseForwardedHeaders
+ var remoteIp = context.Connection.RemoteIpAddress?.ToString();
+ if (!string.IsNullOrEmpty(remoteIp) && remoteIp != "::1" && remoteIp != "127.0.0.1")
+ {
+ return remoteIp;
+ }
+
+ // Fallback para headers de proxy apenas se RemoteIpAddress não disponível
var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(xForwardedFor))
{
@@ -151,7 +158,13 @@ private static string GetClientIpAddress(HttpContext context)
return xRealIp;
}
- return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ // Se RemoteIpAddress for loopback ou null, retornar "unknown"
+ if (string.IsNullOrEmpty(remoteIp) || remoteIp == "::1" || remoteIp == "127.0.0.1")
+ {
+ return "unknown";
+ }
+
+ return remoteIp;
}
private static string GetUserId(HttpContext context)
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/CorsOptions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/CorsOptions.cs
deleted file mode 100644
index 926259876..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/CorsOptions.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.Diagnostics.CodeAnalysis;
-
-namespace MeAjudaAi.ApiService.Options;
-
-[ExcludeFromCodeCoverage]
-public class CorsOptions
-{
- public const string SectionName = "Cors";
-
- [Required]
- public List AllowedOrigins { get; set; } = [];
-
- [Required]
- public List AllowedMethods { get; set; } = [];
-
- [Required]
- public List AllowedHeaders { get; set; } = [];
-
- ///
- /// Indica se deve permitir credenciais em requisições CORS.
- /// Padrão é false por segurança.
- ///
- public bool AllowCredentials { get; set; } = false;
-
- ///
- /// Tempo máximo do cache do preflight em segundos.
- /// Padrão é 1 hora (3600 segundos).
- ///
- public int PreflightMaxAge { get; set; } = 3600;
-
- public void Validate()
- {
- if (!AllowedOrigins.Any())
- throw new InvalidOperationException("At least one allowed origin must be configured for CORS.");
-
- if (!AllowedMethods.Any())
- throw new InvalidOperationException("At least one allowed method must be configured for CORS.");
-
- if (!AllowedHeaders.Any())
- throw new InvalidOperationException("At least one allowed header must be configured for CORS.");
-
- if (PreflightMaxAge < 0)
- throw new InvalidOperationException("PreflightMaxAge must be non-negative.");
-
- // Validação do formato das origens
- foreach (var origin in AllowedOrigins)
- {
- if (string.IsNullOrWhiteSpace(origin))
- throw new InvalidOperationException("CORS allowed origins cannot contain empty values.");
-
- if (origin != "*" && !Uri.TryCreate(origin, UriKind.Absolute, out _))
- throw new InvalidOperationException($"Invalid CORS origin format: {origin}");
- }
-
- // Validação de segurança: alerta se usar coringa em ambientes de produção
- if (AllowedOrigins.Contains("*") && AllowCredentials)
- throw new InvalidOperationException("Cannot use wildcard origin (*) with credentials enabled for security reasons.");
- }
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/GeographicRestrictionOptions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/GeographicRestrictionOptions.cs
deleted file mode 100644
index 448dd43d4..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/GeographicRestrictionOptions.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-
-namespace MeAjudaAi.ApiService.Options;
-
-///
-/// Opções de configuração para restrição geográfica.
-/// Permite limitar acesso da plataforma a cidades/estados específicos (MVP piloto).
-///
-///
-/// NOTA: O controle de habilitação é feito via Microsoft.FeatureManagement (FeatureFlags.GeographicRestriction).
-/// Esta classe contém apenas a configuração de quais regiões são permitidas.
-///
-[ExcludeFromCodeCoverage]
-public class GeographicRestrictionOptions
-{
- ///
- /// Lista de estados permitidos (siglas de 2 letras, ex: "SP", "RJ").
- /// Se vazio, validação de estado é ignorada.
- ///
- public List AllowedStates { get; set; } = [];
-
- ///
- /// Lista de cidades permitidas (nomes completos, ex: "São Paulo").
- /// Validação case-insensitive.
- /// Se vazia, a validação geográfica será ignorada.
- ///
- public List AllowedCities { get; set; } = [];
-
- ///
- /// Mensagem exibida quando acesso é bloqueado.
- /// Placeholder {allowedRegions} será substituído pelas regiões permitidas pelo GeographicRestrictionMiddleware.
- ///
- public string BlockedMessage { get; set; } =
- "Serviço indisponível na sua região. Disponível apenas em: {allowedRegions}";
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AnonymousLimits.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AnonymousLimits.cs
deleted file mode 100644
index 29265c354..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AnonymousLimits.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.Diagnostics.CodeAnalysis;
-
-namespace MeAjudaAi.ApiService.Options.RateLimit;
-
-[ExcludeFromCodeCoverage]
-public class AnonymousLimits
-{
- [Range(1, int.MaxValue)] public int RequestsPerMinute { get; set; } = 30;
- [Range(1, int.MaxValue)] public int RequestsPerHour { get; set; } = 300;
- [Range(1, int.MaxValue)] public int RequestsPerDay { get; set; } = 1000;
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AuthenticatedLimits.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AuthenticatedLimits.cs
deleted file mode 100644
index d77324756..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/AuthenticatedLimits.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace MeAjudaAi.ApiService.Options.RateLimit;
-
-public class AuthenticatedLimits
-{
- [Range(1, int.MaxValue)] public int RequestsPerMinute { get; set; } = 120;
- [Range(1, int.MaxValue)] public int RequestsPerHour { get; set; } = 2000;
- [Range(1, int.MaxValue)] public int RequestsPerDay { get; set; } = 10000;
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/EndpointLimits.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/EndpointLimits.cs
deleted file mode 100644
index ef719062d..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/EndpointLimits.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace MeAjudaAi.ApiService.Options.RateLimit;
-
-public class EndpointLimits
-{
- [Required, MinLength(1)] public string Pattern { get; set; } = string.Empty; // supports * wildcard
- [Range(1, int.MaxValue)] public int RequestsPerMinute { get; set; } = 60;
- [Range(1, int.MaxValue)] public int RequestsPerHour { get; set; } = 1000;
- public bool ApplyToAuthenticated { get; set; } = true;
- public bool ApplyToAnonymous { get; set; } = true;
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/GeneralSettings.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/GeneralSettings.cs
deleted file mode 100644
index 6547a760e..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/GeneralSettings.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace MeAjudaAi.ApiService.Options.RateLimit;
-
-public class GeneralSettings
-{
- public bool Enabled { get; set; } = true;
- [Range(1, 86400)] public int WindowInSeconds { get; set; } = 60;
- public bool EnableIpWhitelist { get; set; } = false;
- public List WhitelistedIps { get; set; } = [];
- public bool EnableDetailedLogging { get; set; } = true;
- public string ErrorMessage { get; set; } = "Rate limit exceeded. Please try again later.";
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/RoleLimits.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/RoleLimits.cs
deleted file mode 100644
index 1ba4631c3..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimit/RoleLimits.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace MeAjudaAi.ApiService.Options.RateLimit;
-
-public class RoleLimits
-{
- [Range(1, int.MaxValue)] public int RequestsPerMinute { get; set; } = 200;
- [Range(1, int.MaxValue)] public int RequestsPerHour { get; set; } = 5000;
- [Range(1, int.MaxValue)] public int RequestsPerDay { get; set; } = 20000;
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs
deleted file mode 100644
index 979b7dd77..000000000
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using MeAjudaAi.ApiService.Options.RateLimit;
-
-namespace MeAjudaAi.ApiService.Options;
-
-///
-/// Opções para Rate Limiting com suporte a usuários autenticados.
-///
-[ExcludeFromCodeCoverage]
-public class RateLimitOptions
-{
- public const string SectionName = "AdvancedRateLimit";
-
- ///
- /// Configurações para usuários anônimos (não autenticados).
- ///
- public AnonymousLimits Anonymous { get; set; } = new();
-
- ///
- /// Configurações para usuários autenticados.
- ///
- public AuthenticatedLimits Authenticated { get; set; } = new();
-
- ///
- /// Configurações específicas por endpoint.
- ///
- public Dictionary EndpointLimits { get; set; } = new();
-
- ///
- /// Configurações por role/função do usuário.
- ///
- public Dictionary RoleLimits { get; set; } = new();
-
- ///
- /// Configurações gerais.
- ///
- public GeneralSettings General { get; set; } = new();
-}
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs
index 9c07ae49d..4afed38aa 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs
@@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using MeAjudaAi.ApiService.Endpoints;
using MeAjudaAi.ApiService.Extensions;
+using MeAjudaAi.ApiService.Middlewares;
using MeAjudaAi.Modules.Communications.API;
using MeAjudaAi.Modules.Documents.API;
using MeAjudaAi.Modules.Locations.API;
@@ -16,6 +17,7 @@
using MeAjudaAi.Shared.Extensions;
using MeAjudaAi.Shared.Logging;
using MeAjudaAi.Shared.Seeding;
+using Microsoft.FeatureManagement;
using Serilog;
using Serilog.Context;
@@ -59,7 +61,10 @@ public static async Task Main(string[] args)
// Shared services por último (GlobalExceptionHandler atua como fallback)
builder.Services.AddSharedServices(builder.Configuration);
builder.Services.AddApiServices(builder.Configuration, builder.Environment);
- builder.Services.AddCustomRateLimiting(builder.Configuration);
+
+ builder.Services.AddCors();
+
+ builder.Services.AddFeatureManagement();
var app = builder.Build();
@@ -127,11 +132,21 @@ private static void ConfigureLogging(WebApplicationBuilder builder)
private static async Task ConfigureMiddlewareAsync(WebApplication app)
{
+if (app.Environment.IsEnvironment("Testing") || app.Environment.IsEnvironment("Integration"))
+ {
+ app.UseCors(policy =>
+ {
+ policy.SetIsOriginAllowed(_ => true)
+ .AllowAnyMethod()
+ .AllowAnyHeader()
+ .AllowCredentials();
+ });
+ }
+
app.MapDefaultEndpoints();
// Configurar serviços e módulos
await app.UseSharedServicesAsync();
app.UseApiServices(app.Environment);
- app.UseRateLimiter();
app.UseUsersModule();
app.UseProvidersModule();
app.UseDocumentsModule();
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json b/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json
index cfa20564f..411d8fbba 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json
@@ -26,26 +26,6 @@
"ServiceName": "MeAjudaAi-api",
"ServiceVersion": "1.0.0"
},
- "AdvancedRateLimit": {
- "Anonymous": {
- "RequestsPerMinute": 30,
- "RequestsPerHour": 300,
- "RequestsPerDay": 1000
- },
- "Authenticated": {
- "RequestsPerMinute": 120,
- "RequestsPerHour": 2000,
- "RequestsPerDay": 10000
- },
- "General": {
- "Enabled": true,
- "WindowInSeconds": 60,
- "EnableIpWhitelist": false,
- "WhitelistedIps": [],
- "EnableDetailedLogging": false,
- "ErrorMessage": "RateLimit.Errors.Exceeded"
- }
- },
"Caching": {
"DefaultExpirationMinutes": 30,
"RedisConnectionString": ""
@@ -63,8 +43,7 @@
"BaseUrl": "http://localhost:8080",
"Realm": "meajudaai",
"ClientId": "admin-portal",
- "RequireHttpsMetadata": false,
- "Authority": "http://localhost:8080/realms/meajudaai"
+ "RequireHttpsMetadata": false
},
"ClientBaseUrl": "http://localhost:5165",
"Messaging": {
@@ -89,12 +68,6 @@
"WarmupEnabled": false,
"WarmupTimeoutSeconds": 30
},
- "Cors": {
- "AllowedOrigins": [ "http://localhost:3000", "http://localhost:5173", "http://localhost:5165", "https://localhost:7281" ],
- "AllowedMethods": [ "GET", "POST", "PUT", "DELETE", "PATCH" ],
- "AllowedHeaders": [ "*" ],
- "AllowCredentials": true
- },
"Locations": {
"ExternalApis": {
"ViaCep": {
@@ -115,9 +88,6 @@
}
}
},
- "FeatureManagement": {
- "GeographicRestriction": false
- },
"Communications": {
"EnableStubs": true
},
@@ -137,5 +107,9 @@
"SuccessUrl": "/payment/success?session_id={CHECKOUT_SESSION_ID}",
"CancelUrl": "/payment/cancel",
"AllowedReturnHosts": [ "meajudaai.com" ]
+ },
+ "GeographicRestriction": {
+ "Enabled": false,
+ "FailOpen": true
}
}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json
index 24eed357f..6904fdc96 100644
--- a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json
+++ b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json
@@ -38,6 +38,15 @@
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
}
},
+ "Microsoft.FeatureManagement.AspNetCore": {
+ "type": "Direct",
+ "requested": "[4.5.0, )",
+ "resolved": "4.5.0",
+ "contentHash": "CafI8Ne0xuXRNzHMoRGAX4WRvLxpTE7RceBncgME/fRcPD2KwYrxfZpLyJ/kVfYt8uvHkC2xy0VlStj687mVoA==",
+ "dependencies": {
+ "Microsoft.FeatureManagement": "4.5.0"
+ }
+ },
"Serilog.AspNetCore": {
"type": "Direct",
"requested": "[10.0.0, )",
@@ -1179,15 +1188,6 @@
"Microsoft.Extensions.Resilience": "10.5.0"
}
},
- "Microsoft.FeatureManagement.AspNetCore": {
- "type": "CentralTransitive",
- "requested": "[4.5.0, )",
- "resolved": "4.5.0",
- "contentHash": "CafI8Ne0xuXRNzHMoRGAX4WRvLxpTE7RceBncgME/fRcPD2KwYrxfZpLyJ/kVfYt8uvHkC2xy0VlStj687mVoA==",
- "dependencies": {
- "Microsoft.FeatureManagement": "4.5.0"
- }
- },
"Microsoft.IdentityModel.Protocols": {
"type": "CentralTransitive",
"requested": "[8.16.0, )",
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/MeAjudaAi.Gateway.csproj b/src/Bootstrapper/MeAjudaAi.Gateway/MeAjudaAi.Gateway.csproj
new file mode 100644
index 000000000..2ace59ab0
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/MeAjudaAi.Gateway.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/EdgeAuthGuardMiddleware.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/EdgeAuthGuardMiddleware.cs
new file mode 100644
index 000000000..ea38d7789
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/EdgeAuthGuardMiddleware.cs
@@ -0,0 +1,94 @@
+using MeAjudaAi.Gateway.Options;
+using Microsoft.Extensions.Options;
+
+namespace MeAjudaAi.Gateway.Middlewares;
+
+public class EdgeAuthGuardMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly EdgeAuthGuardOptions _options;
+ private readonly ILogger _logger;
+ private readonly PathString[] _publicPathPrefixes;
+
+ public EdgeAuthGuardMiddleware(
+ RequestDelegate next,
+ IOptions options,
+ ILogger logger)
+ {
+ _next = next;
+ _options = options.Value;
+ _logger = logger;
+
+ _publicPathPrefixes = _options.PublicRoutes
+ .Select(p => new PathString(p))
+ .ToArray();
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (!_options.Enabled)
+ {
+ await _next(context);
+ return;
+ }
+
+ var path = context.Request.Path.Value ?? string.Empty;
+ var pathString = new PathString(path);
+
+ if (!pathString.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
+ {
+ await _next(context);
+ return;
+ }
+
+ var isPublicRoute = false;
+ foreach (var publicPrefix in _publicPathPrefixes)
+ {
+ if (pathString.StartsWithSegments(publicPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ isPublicRoute = true;
+ break;
+ }
+ }
+
+ context.Items["X-Gateway-PublicRoute"] = isPublicRoute;
+
+ var isAuthenticated = context.User.Identity?.IsAuthenticated ?? false;
+
+ if (!isPublicRoute && !isAuthenticated)
+ {
+ _logger.LogWarning(
+ "Edge auth guard blocked request to {Path} from {IpAddress}",
+ path,
+ context.Connection.RemoteIpAddress);
+
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ context.Response.Headers[_options.ChallengeHeader] = "true";
+ context.Response.ContentType = "application/json";
+
+ var errorResponse = new
+ {
+ error = "authentication_required",
+ message = "Autenticação necessária. Por favor, forneça um token válido."
+ };
+
+ await context.Response.WriteAsJsonAsync(errorResponse);
+ return;
+ }
+
+ if (isAuthenticated)
+ {
+ context.Response.Headers[_options.AuthenticatedHeader] = "true";
+ }
+
+ await _next(context);
+ }
+}
+
+public static class EdgeAuthGuardMiddlewareExtensions
+{
+ public static IApplicationBuilder UseEdgeAuthGuard(this IApplicationBuilder app)
+ {
+ return app.UseMiddleware();
+ }
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/ResilientForwarderHttpClientFactory.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/ResilientForwarderHttpClientFactory.cs
new file mode 100644
index 000000000..f6aea2875
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Middlewares/ResilientForwarderHttpClientFactory.cs
@@ -0,0 +1,151 @@
+using System.Net;
+using MeAjudaAi.Gateway.Options;
+using Microsoft.Extensions.Options;
+using Yarp.ReverseProxy.Forwarder;
+
+namespace MeAjudaAi.Gateway.Middlewares;
+
+public class ResilientForwarderHttpClientFactory : IForwarderHttpClientFactory
+{
+ private readonly GatewayResilienceOptions _options;
+ private readonly ILogger _logger;
+ private static readonly HashSet DefaultRetryableMethods =
+ new(["GET", "HEAD", "OPTIONS"], StringComparer.OrdinalIgnoreCase);
+
+ public ResilientForwarderHttpClientFactory(
+ IOptions options,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context)
+ {
+ var handler = CreateHandler(context);
+ var invoker = new HttpMessageInvoker(handler);
+ return invoker;
+ }
+
+ public HttpMessageHandler CreateHandler(ForwarderHttpClientContext context)
+ {
+ _logger.LogDebug(
+ "Creating resilient HttpHandler with timeout {Timeout}s and retry count {RetryCount}",
+ _options.TimeoutSeconds,
+ _options.RetryCount);
+
+ var socketsHandler = new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(2),
+ PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
+ MaxConnectionsPerServer = 100,
+ EnableMultipleHttp2Connections = true,
+ ConnectTimeout = TimeSpan.FromSeconds(_options.TimeoutSeconds),
+ ResponseDrainTimeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
+ };
+
+ if (_options.RetryCount > 0)
+ {
+ return new RetryDelegatingHandler(_options, _logger) { InnerHandler = socketsHandler };
+ }
+
+ return socketsHandler;
+ }
+}
+
+internal sealed class RetryDelegatingHandler : DelegatingHandler
+{
+ private readonly GatewayResilienceOptions _options;
+ private readonly ILogger _logger;
+ private static readonly HashSet DefaultRetryableMethods =
+ new(["GET", "HEAD", "OPTIONS"], StringComparer.OrdinalIgnoreCase);
+
+ public RetryDelegatingHandler(GatewayResilienceOptions options, ILogger logger)
+ {
+ _options = options;
+ _logger = logger;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
+
+ var retryableMethods = _options.RetryableMethods?.Count > 0
+ ? _options.RetryableMethods
+ : DefaultRetryableMethods.ToList();
+
+ var allowRetry = retryableMethods.Contains(request.Method.Method, StringComparer.OrdinalIgnoreCase);
+
+ if (!allowRetry || _options.RetryCount <= 0)
+ {
+ return await base.SendAsync(request, timeoutCts.Token);
+ }
+
+ HttpResponseMessage? last = null;
+ Exception? lastException = null;
+
+ for (int attempt = 0; attempt <= _options.RetryCount; attempt++)
+ {
+ try
+ {
+ last = await base.SendAsync(request, timeoutCts.Token);
+
+ if (!IsTransient(last))
+ {
+ return last;
+ }
+
+ _logger.LogWarning(
+ "Retry attempt {AttemptNumber}/{MaxAttempts} for {Method} {Url} - Status: {StatusCode}",
+ attempt + 1,
+ _options.RetryCount,
+ request.Method.Method,
+ request.RequestUri,
+ last.StatusCode);
+
+ if (attempt < _options.RetryCount)
+ {
+ last.Dispose();
+ last = null;
+ }
+ }
+ catch (Exception ex) when (IsTransientException(ex))
+ {
+ lastException = ex;
+ _logger.LogWarning(
+ "Retry attempt {AttemptNumber}/{MaxAttempts} for {Method} {Url} - Exception: {Message}",
+ attempt + 1,
+ _options.RetryCount,
+ request.Method.Method,
+ request.RequestUri,
+ ex.Message);
+ }
+
+ if (attempt < _options.RetryCount)
+ {
+ var delay = TimeSpan.FromMilliseconds(_options.RetryBaseDelayMs * Math.Pow(2, attempt));
+ await Task.Delay(delay, timeoutCts.Token);
+ }
+ }
+
+ if (last != null)
+ {
+ return last;
+ }
+
+ if (lastException != null)
+ {
+ throw lastException;
+ }
+
+ throw new HttpRequestException("All retry attempts failed");
+ }
+
+ private static bool IsTransient(HttpResponseMessage response) =>
+ (int)response.StatusCode >= 500 ||
+ response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout or HttpStatusCode.GatewayTimeout;
+
+ private static bool IsTransientException(Exception ex) =>
+ ex is HttpRequestException or TaskCanceledException or IOException;
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Options/EdgeAuthGuardOptions.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Options/EdgeAuthGuardOptions.cs
new file mode 100644
index 000000000..37e642cd5
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Options/EdgeAuthGuardOptions.cs
@@ -0,0 +1,20 @@
+namespace MeAjudaAi.Gateway.Options;
+
+public class EdgeAuthGuardOptions
+{
+ public const string SectionName = "EdgeAuthGuard";
+
+ public bool Enabled { get; set; } = true;
+ public List PublicRoutes { get; set; } = [
+ "/health",
+ "/swagger",
+ "/api/v1/auth/login",
+ "/api/v1/auth/register",
+ "/api/v1/providers/public",
+ "/api/v1/customers/register",
+ "/api/v1/providers/register",
+ "/webhooks/stripe"
+ ];
+ public string ChallengeHeader { get; set; } = "X-Gateway-Challenge";
+ public string AuthenticatedHeader { get; set; } = "X-Gateway-Authenticated";
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayCorsOptions.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayCorsOptions.cs
new file mode 100644
index 000000000..5d98a1570
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayCorsOptions.cs
@@ -0,0 +1,11 @@
+namespace MeAjudaAi.Gateway.Options;
+
+public class GatewayCorsOptions
+{
+ public const string SectionName = "Cors";
+ public List AllowedOrigins { get; set; } = [];
+ public List AllowedMethods { get; set; } = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
+ public List AllowedHeaders { get; set; } = ["*"];
+ public bool AllowCredentials { get; set; } = true;
+ public int MaxAgeSeconds { get; set; } = 3600;
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayResilienceOptions.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayResilienceOptions.cs
new file mode 100644
index 000000000..493978f92
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Options/GatewayResilienceOptions.cs
@@ -0,0 +1,11 @@
+namespace MeAjudaAi.Gateway.Options;
+
+public class GatewayResilienceOptions
+{
+ public const string SectionName = "GatewayResilience";
+
+ public int TimeoutSeconds { get; set; } = 30;
+ public int RetryCount { get; set; } = 3;
+ public int RetryBaseDelayMs { get; set; } = 100;
+ public List RetryableMethods { get; set; } = ["GET", "HEAD", "OPTIONS"];
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/Program.cs b/src/Bootstrapper/MeAjudaAi.Gateway/Program.cs
new file mode 100644
index 000000000..50be2fae1
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/Program.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using MeAjudaAi.Gateway.Middlewares;
+using MeAjudaAi.Gateway.Options;
+using MeAjudaAi.ServiceDefaults;
+using MeAjudaAi.Shared.Geolocation;
+using MeAjudaAi.Shared.Middleware;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.FeatureManagement;
+using Yarp.ReverseProxy.Forwarder;
+using Yarp.ReverseProxy.Transforms;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.Configure(
+ builder.Configuration.GetSection(GatewayCorsOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(GeographicRestrictionOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(RateLimitingOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(GatewayResilienceOptions.SectionName));
+builder.Services.Configure(
+ builder.Configuration.GetSection(EdgeAuthGuardOptions.SectionName));
+
+builder.Services.AddMemoryCache();
+builder.Services.AddFeatureManagement();
+
+var corsConfig = builder.Configuration.GetSection(GatewayCorsOptions.SectionName).Get() ?? new GatewayCorsOptions();
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ if (corsConfig.AllowedOrigins.Contains("*"))
+ {
+ policy.SetIsOriginAllowed(_ => true);
+ }
+ else
+ {
+ policy.WithOrigins(corsConfig.AllowedOrigins.ToArray());
+ }
+
+ policy.WithMethods(corsConfig.AllowedMethods.ToArray());
+
+ if (corsConfig.AllowedHeaders.Contains("*"))
+ {
+ policy.AllowAnyHeader();
+ }
+ else
+ {
+ policy.WithHeaders(corsConfig.AllowedHeaders.ToArray());
+ }
+
+ if (corsConfig.AllowCredentials)
+ {
+ policy.AllowCredentials();
+ }
+
+ policy.SetPreflightMaxAge(TimeSpan.FromSeconds(corsConfig.MaxAgeSeconds));
+ });
+});
+
+var keycloakBaseUrl = builder.Configuration["Keycloak:BaseUrl"] ?? "http://localhost:8080";
+var keycloakRealm = builder.Configuration["Keycloak:Realm"] ?? "meajudaai";
+var keycloakClientId = builder.Configuration["Keycloak:ClientId"] ?? "admin-portal";
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = $"{keycloakBaseUrl}/realms/{keycloakRealm}";
+ options.Audience = keycloakClientId;
+ options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+ options.TokenValidationParameters = new()
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ ClockSkew = TimeSpan.FromMinutes(5)
+ };
+ });
+
+builder.Services.AddAuthorization();
+
+var timeoutSeconds = builder.Configuration.GetValue("GatewayResilience:TimeoutSeconds", 30);
+
+builder.Services.AddSingleton();
+
+builder.Services.AddReverseProxy()
+ .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
+ .AddTransforms(transforms =>
+ {
+ transforms.AddRequestTransform(async context =>
+ {
+ context.ProxyRequest.Headers.Add("X-Forwarded-For", context.HttpContext.Connection.RemoteIpAddress?.ToString());
+ context.ProxyRequest.Headers.Add("X-Forwarded-Proto", context.HttpContext.Request.Scheme);
+ context.ProxyRequest.Headers.Add("X-Original-Host", context.HttpContext.Request.Host.ToString());
+ context.ProxyRequest.Headers.Add("X-Gateway-Name", "MeAjudaAi-Gateway");
+ context.ProxyRequest.Headers.Add("X-Request-Timeout", timeoutSeconds.ToString());
+ });
+ });
+
+var app = builder.Build();
+
+app.UseCors();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.UseEdgeAuthGuard();
+
+app.UseMiddleware();
+
+app.MapDefaultEndpoints();
+
+app.UseMiddleware();
+
+app.MapReverseProxy();
+
+app.Run();
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/appsettings.json b/src/Bootstrapper/MeAjudaAi.Gateway/appsettings.json
new file mode 100644
index 000000000..a6e1f6881
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/appsettings.json
@@ -0,0 +1,104 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Cors": {
+ "AllowedOrigins": [ "http://localhost:3000", "http://localhost:3001", "http://localhost:3002", "http://localhost:5173" ],
+ "AllowedMethods": [ "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" ],
+ "AllowedHeaders": [ "*" ],
+ "AllowCredentials": true,
+ "MaxAgeSeconds": 3600
+ },
+ "RateLimiting": {
+ "General": {
+ "Enabled": true,
+ "WindowInSeconds": 60,
+ "EnableIpWhitelist": false,
+ "WhitelistedIps": [],
+ "ErrorMessage": "Limite de requisições excedido. Tente novamente mais tarde."
+ },
+ "Anonymous": {
+ "RequestsPerMinute": 30,
+ "RequestsPerHour": 300,
+ "RequestsPerDay": 1000
+ },
+ "Authenticated": {
+ "RequestsPerMinute": 120,
+ "RequestsPerHour": 2000,
+ "RequestsPerDay": 10000
+ }
+ },
+ "GeographicRestriction": {
+ "Enabled": false,
+ "FailOpen": true,
+ "AllowedStates": [ "ES", "MG", "RJ", "SP" ],
+ "AllowedCities": [],
+ "BlockedMessage": "Acesso não permitido para esta região. Por favor, entre em contato com o suporte.",
+ "DefaultBlockedMessage": "Acesso da sua região não permitido. Regiões permitidas: {allowedRegions}."
+ },
+ "Keycloak": {
+ "BaseUrl": "http://localhost:8080",
+ "Realm": "meajudaai",
+ "ClientId": "admin-portal"
+ },
+ "ReverseProxy": {
+ "Routes": {
+ "api-route": {
+ "ClusterId": "api-cluster",
+ "Match": {
+ "Path": "/api/{**catch-all}"
+ }
+ },
+ "health-route": {
+ "ClusterId": "api-cluster",
+ "Match": {
+ "Path": "/health/{**catch-all}"
+ }
+ }
+ },
+ "Clusters": {
+ "api-cluster": {
+ "HttpClient": {
+ "ActivityTimeout": "00:00:45"
+ },
+ "HealthCheck": {
+ "Enabled": true,
+ "Interval": "00:00:10",
+ "Timeout": "00:00:05",
+ "Policy": "ConsecutiveFailures",
+ "Path": "/health"
+ },
+ "Destinations": {
+ "destination1": {
+ "Address": "https://apiservice"
+ }
+ }
+ }
+ }
+ },
+ "GatewayResilience": {
+ "TimeoutSeconds": 30,
+ "RetryCount": 3,
+ "RetryBaseDelayMs": 100,
+ "RetryableMethods": [ "GET", "HEAD", "OPTIONS" ]
+ },
+ "EdgeAuthGuard": {
+ "Enabled": true,
+ "PublicRoutes": [
+ "/health",
+ "/swagger",
+ "/api/v1/auth/login",
+ "/api/v1/auth/register",
+ "/api/v1/providers/public",
+ "/api/v1/customers/register",
+ "/api/v1/providers/register",
+ "/webhooks/stripe"
+ ],
+ "ChallengeHeader": "X-Gateway-Challenge",
+ "AuthenticatedHeader": "X-Gateway-Authenticated"
+ }
+}
\ No newline at end of file
diff --git a/src/Bootstrapper/MeAjudaAi.Gateway/packages.lock.json b/src/Bootstrapper/MeAjudaAi.Gateway/packages.lock.json
new file mode 100644
index 000000000..105f82ea0
--- /dev/null
+++ b/src/Bootstrapper/MeAjudaAi.Gateway/packages.lock.json
@@ -0,0 +1,1033 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.AspNetCore.Authentication.JwtBearer": {
+ "type": "Direct",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "g8klpd7OFJfJOq1EJKcBO8C8I8Dp0QUWoKDPUvvJYe+xunVyBHq6YxfF2CAc6+rkniV25iaWl+6RK87c25n4lA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
+ }
+ },
+ "Microsoft.Extensions.Http.Polly": {
+ "type": "Direct",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "pcUsPoqMHvOp+QJsLA/Hlg/W+IBnAoUXKEBc7FqMcY0sUez15DOKXtbEo81TvHL9xwjWQcF3ZMayNpcvpI7Bqg==",
+ "dependencies": {
+ "Polly": "7.2.4",
+ "Polly.Extensions.Http": "3.0.0"
+ }
+ },
+ "Microsoft.FeatureManagement.AspNetCore": {
+ "type": "Direct",
+ "requested": "[4.5.0, )",
+ "resolved": "4.5.0",
+ "contentHash": "CafI8Ne0xuXRNzHMoRGAX4WRvLxpTE7RceBncgME/fRcPD2KwYrxfZpLyJ/kVfYt8uvHkC2xy0VlStj687mVoA==",
+ "dependencies": {
+ "Microsoft.FeatureManagement": "4.5.0"
+ }
+ },
+ "SonarAnalyzer.CSharp": {
+ "type": "Direct",
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
+ },
+ "Yarp.ReverseProxy": {
+ "type": "Direct",
+ "requested": "[2.3.0, )",
+ "resolved": "2.3.0",
+ "contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
+ "dependencies": {
+ "System.IO.Hashing": "8.0.0"
+ }
+ },
+ "Asp.Versioning.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "cMRE5nvNMfBgfkb0XFWst/7UtyXCjoAXnV0L4Scx4P9fcf0idgrj1Z0c+3ylsy01K4cOib7dKhCBfpg5z3r0Kg=="
+ },
+ "Azure.Core": {
+ "type": "Transitive",
+ "resolved": "1.50.0",
+ "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.ClientModel": "1.8.0",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "Azure.Monitor.OpenTelemetry.Exporter": {
+ "type": "Transitive",
+ "resolved": "1.5.0",
+ "contentHash": "7YgW82V13PwhjrlaN2Nbu9UIvYMzZxjgV9TYqK34PK+81IWsDwPO3vBhyeHYpDBwKWm7wqHp1c3VVX5DN4G2WA==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "OpenTelemetry": "1.14.0",
+ "OpenTelemetry.Extensions.Hosting": "1.14.0",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ }
+ },
+ "Dapper.AOT": {
+ "type": "Transitive",
+ "resolved": "1.0.48",
+ "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
+ },
+ "Hangfire.NetCore": {
+ "type": "Transitive",
+ "resolved": "1.8.23",
+ "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==",
+ "dependencies": {
+ "Hangfire.Core": "[1.8.23]"
+ }
+ },
+ "Humanizer.Core": {
+ "type": "Transitive",
+ "resolved": "2.14.1",
+ "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
+ "Microsoft.Bcl.TimeProvider": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA=="
+ },
+ "Microsoft.CodeAnalysis.Analyzers": {
+ "type": "Transitive",
+ "resolved": "3.11.0",
+ "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
+ },
+ "Microsoft.CodeAnalysis.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "17.11.31",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
+ "Newtonsoft.Json": "13.0.3",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "TuxExnfIS/bSq3z2CbH0LwZH1oyj9iHhSGneU4fpxl3ikjZGZdSae9gcfnImV1rufH8f/ab1NnHwyL2BLyeZOg=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "eZnMyiJzo249Ejg5CaFScvJS0u7neQfS9DXknAHTO6FHVMM99gO0byNXHGZmA/BOkZ13ngeVziQLHTMOtgescg=="
+ },
+ "Microsoft.Extensions.AmbientMetadata.Application": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "lCJjEDknSYeTXB133DwLNwXYA6q9nzJiJFjQb1KO1n3sS6wHfROm6zqG6y3UthQP5oPnNbE1a7M15LpjSf5yBg=="
+ },
+ "Microsoft.Extensions.Compliance.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "xbWZji13Vb2jDJNtwVrKpI09jd8x3n3fL+GzhiLK+8O5Wc2A+GyqCZalST2fV46Pf0QfCwkXf83y+3/rDkCd7A=="
+ },
+ "Microsoft.Extensions.DependencyInjection.AutoActivation": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "vby/PzPScy9pX3r3f5UuHutxSr4Q8SXqyIiH6+JEK7SVpTCL6f8R9mp04OUVsZLlsME2rBjA9PHXf9L9aG7wbg=="
+ },
+ "Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "+jdC9YUfMkX9/Yb3Pi8Kovt1nFVGGB2UqSHZgLapo63d+WAhYf9KiuNA3jiaaRINhVyCgWuKFoMtjWKET5oXEQ=="
+ },
+ "Microsoft.Extensions.Http.Diagnostics": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "HoWdJKvBt7vkLlclRbjDTXcCp3s9hwFf1CY4ovlmMKFAbKSI7zKl0fUQ4LMvUI3sHIhpEtMjp7Mxjaf/yEmVvQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Telemetry": "10.5.0"
+ }
+ },
+ "Microsoft.Extensions.Resilience": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "yjbGQkSqLkP8/lKZLfaUcdkNUpWUqMafCsm56kw9uzznhJb/uJiIRy5/zG9D0SFsBzJkz2AcvWU2J/MJydPxoA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.5.0",
+ "Microsoft.Extensions.Telemetry.Abstractions": "10.5.0",
+ "Polly.Extensions": "8.4.2",
+ "Polly.RateLimiting": "8.4.2"
+ }
+ },
+ "Microsoft.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "jI7b9rkfoz06ZEQols6WG3D0iQMIbtRDHkx1F7QvQOSDmzyXLwUIBbJEO8ftr7aD/2tvsHplqycp+WXFvMfujg==",
+ "dependencies": {
+ "Microsoft.Extensions.AmbientMetadata.Application": "10.5.0",
+ "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.5.0",
+ "Microsoft.Extensions.Telemetry.Abstractions": "10.5.0"
+ }
+ },
+ "Microsoft.Extensions.Telemetry.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "VmU7e6xHqoubWKl7y9MtWyQAjlDpvbds3gY8ZKMS/1GxY2+U1/aMNnMj09aOXAa3p5qhHSSkBzDJvyokCjVkPg==",
+ "dependencies": {
+ "Microsoft.Extensions.Compliance.Abstractions": "10.5.0"
+ }
+ },
+ "Microsoft.FeatureManagement": {
+ "type": "Transitive",
+ "resolved": "4.5.0",
+ "contentHash": "9VBxTZUwna9x31+OmOyX0DTBGEuvVrV81fAZ/XRkLESuDu5EZn/o6W8454NWOPHBIUyTGTMpYOFzI8ArkCNmCg==",
+ "dependencies": {
+ "Microsoft.Bcl.TimeProvider": "8.0.1"
+ }
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "6NrxQGcZg6IunkN8K2F0UVMavNpfCjbjjjON7PYcL8FwI8aULKUreiHsRX/yaA8j3XsTJnQKUYpoQk5gBjULZw=="
+ },
+ "Microsoft.IdentityModel.JsonWebTokens": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "JbFZ3OVwtvqcqgBL0cIkhRYbIP7u9GIUYLOgbNqLWtBtZY8tGDpdGyXMzUVX0gVHq1ovuHsKZrkVv+ziHEnBHw==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.17.0"
+ }
+ },
+ "Microsoft.IdentityModel.Logging": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "w1vjfri0BWqW7RkSZY3ZsqekNfIJJg5BQSFs2j+a+pCXOVrkezmJcn74pT3djwjXJh71577C6wJQgNc2UPz30w==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.17.0"
+ }
+ },
+ "Microsoft.IdentityModel.Tokens": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "teaW35URIV2x78Tzk+dVJiC4M62/9mQoSEoDjDGoEZmcQa3H2rE+XQpm9Tmdo9KK1Lcrnve4zoyLavl69kCFGg==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Logging": "8.17.0"
+ }
+ },
+ "Microsoft.VisualStudio.SolutionPersistence": {
+ "type": "Transitive",
+ "resolved": "1.0.52",
+ "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
+ },
+ "Mono.TextTemplating": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "Npgsql.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.1",
+ "contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
+ "dependencies": {
+ "Npgsql": "10.0.1"
+ }
+ },
+ "Npgsql.OpenTelemetry": {
+ "type": "Transitive",
+ "resolved": "10.0.1",
+ "contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
+ "dependencies": {
+ "Npgsql": "10.0.1",
+ "OpenTelemetry.API": "1.14.0"
+ }
+ },
+ "OpenTelemetry": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Api": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
+ },
+ "OpenTelemetry.Api.ProviderBuilderExtensions": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
+ "dependencies": {
+ "OpenTelemetry.Api": "1.15.3"
+ }
+ },
+ "OpenTelemetry.PersistentStorage.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.0.2",
+ "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ },
+ "OpenTelemetry.PersistentStorage.FileSystem": {
+ "type": "Transitive",
+ "resolved": "1.0.2",
+ "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "dependencies": {
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ }
+ },
+ "Pipelines.Sockets.Unofficial": {
+ "type": "Transitive",
+ "resolved": "2.2.8",
+ "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ=="
+ },
+ "Polly.Core": {
+ "type": "Transitive",
+ "resolved": "8.6.6",
+ "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ=="
+ },
+ "Polly.Extensions": {
+ "type": "Transitive",
+ "resolved": "8.4.2",
+ "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
+ "dependencies": {
+ "Polly.Core": "8.4.2"
+ }
+ },
+ "Polly.Extensions.Http": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==",
+ "dependencies": {
+ "Polly": "7.1.0"
+ }
+ },
+ "Polly.RateLimiting": {
+ "type": "Transitive",
+ "resolved": "8.4.2",
+ "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
+ "dependencies": {
+ "Polly.Core": "8.4.2"
+ }
+ },
+ "Serilog.Extensions.Hosting": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==",
+ "dependencies": {
+ "Serilog": "4.3.0",
+ "Serilog.Extensions.Logging": "10.0.0"
+ }
+ },
+ "Serilog.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==",
+ "dependencies": {
+ "Serilog": "4.2.0"
+ }
+ },
+ "Serilog.Formatting.Compact": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.Debug": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.File": {
+ "type": "Transitive",
+ "resolved": "7.0.0",
+ "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==",
+ "dependencies": {
+ "Serilog": "4.2.0"
+ }
+ },
+ "StackExchange.Redis": {
+ "type": "Transitive",
+ "resolved": "2.7.27",
+ "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==",
+ "dependencies": {
+ "Pipelines.Sockets.Unofficial": "2.2.8"
+ }
+ },
+ "System.ClientModel": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==",
+ "dependencies": {
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Composition": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Convention": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0",
+ "System.Composition.TypedParts": "9.0.0"
+ }
+ },
+ "System.Composition.AttributedModel": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
+ },
+ "System.Composition.Convention": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0"
+ }
+ },
+ "System.Composition.Hosting": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
+ "dependencies": {
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Composition.Runtime": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
+ },
+ "System.Composition.TypedParts": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Memory.Data": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg=="
+ },
+ "meajudaai.contracts": {
+ "type": "Project",
+ "dependencies": {
+ "FluentValidation": "[12.1.1, )"
+ }
+ },
+ "meajudaai.modules.locations.application": {
+ "type": "Project",
+ "dependencies": {
+ "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.modules.locations.domain": {
+ "type": "Project",
+ "dependencies": {
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.modules.locations.infrastructure": {
+ "type": "Project",
+ "dependencies": {
+ "EFCore.NamingConventions": "[10.0.1, )",
+ "MeAjudaAi.Modules.Locations.Application": "[1.0.0, )",
+ "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.servicedefaults": {
+ "type": "Project",
+ "dependencies": {
+ "Aspire.Npgsql": "[13.2.4, )",
+ "Azure.Monitor.OpenTelemetry.AspNetCore": "[1.4.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )",
+ "Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
+ "OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.14.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
+ }
+ },
+ "meajudaai.shared": {
+ "type": "Project",
+ "dependencies": {
+ "Asp.Versioning.Mvc": "[10.0.0, )",
+ "Asp.Versioning.Mvc.ApiExplorer": "[10.0.0, )",
+ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )",
+ "AspNetCore.HealthChecks.Redis": "[9.0.0, )",
+ "Dapper": "[2.1.72, )",
+ "EFCore.NamingConventions": "[10.0.1, )",
+ "FluentValidation": "[12.1.1, )",
+ "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )",
+ "Hangfire.AspNetCore": "[1.8.23, )",
+ "Hangfire.Core": "[1.8.23, )",
+ "Hangfire.PostgreSql": "[1.21.1, )",
+ "MeAjudaAi.Contracts": "[1.0.0, )",
+ "Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
+ "Microsoft.EntityFrameworkCore": "[10.0.7, )",
+ "Microsoft.EntityFrameworkCore.Design": "[10.0.7, )",
+ "Microsoft.Extensions.Caching.Hybrid": "[10.5.0, )",
+ "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
+ "Newtonsoft.Json": "[13.0.4, )",
+ "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )",
+ "RabbitMQ.Client": "[7.2.1, )",
+ "Rebus": "[8.9.2, )",
+ "Rebus.RabbitMq": "[10.1.1, )",
+ "Rebus.ServiceProvider": "[10.7.2, )",
+ "Scrutor": "[7.0.0, )",
+ "Serilog": "[4.3.1, )",
+ "Serilog.AspNetCore": "[10.0.0, )",
+ "Serilog.Enrichers.Environment": "[3.0.1, )",
+ "Serilog.Enrichers.Process": "[3.0.0, )",
+ "Serilog.Enrichers.Thread": "[4.0.0, )",
+ "Serilog.Settings.Configuration": "[10.0.0, )",
+ "Serilog.Sinks.Console": "[6.1.1, )",
+ "Serilog.Sinks.Seq": "[9.0.0, )"
+ }
+ },
+ "Asp.Versioning.Http": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "xmNm9FM2d20NKy7i1osEQysf7pJ4iJjWnM6e8CoeIhUREqG8nugsfC82pGpmzlatjAJL5T52ieSpyW+GFdSsSQ==",
+ "dependencies": {
+ "Asp.Versioning.Abstractions": "10.0.0"
+ }
+ },
+ "Asp.Versioning.Mvc": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "W0wZ+0uZ0UK4KstjvEkNBZ0xxhBmxunwNg8582SVyyW7txQmSXibtm8fC4o82LaemPquYskms67bIbJOSrnlug==",
+ "dependencies": {
+ "Asp.Versioning.Http": "10.0.0"
+ }
+ },
+ "Asp.Versioning.Mvc.ApiExplorer": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "H54UOpRoc4RmhQ4RA2lzDz43a/hAu/JN19Yyy/DNmH4XlRxhemfhifJyh9BaXNJOtGa2Dnu2xEeP4VSiTdUdAg==",
+ "dependencies": {
+ "Asp.Versioning.Mvc": "10.0.0"
+ }
+ },
+ "Aspire.Npgsql": {
+ "type": "CentralTransitive",
+ "requested": "[13.2.4, )",
+ "resolved": "13.2.4",
+ "contentHash": "FfKx0Jzv6n1VelJcuRaSmLvyIUjm9x4AAk3Yq5LMlLsORLDi4ABAev8Bns+5075qUt2fF6A2zYPcXVlTiAZemg==",
+ "dependencies": {
+ "AspNetCore.HealthChecks.NpgSql": "9.0.0",
+ "Npgsql.DependencyInjection": "10.0.1",
+ "Npgsql.OpenTelemetry": "10.0.1",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3"
+ }
+ },
+ "AspNetCore.HealthChecks.NpgSql": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
+ "dependencies": {
+ "Npgsql": "8.0.3"
+ }
+ },
+ "AspNetCore.HealthChecks.Redis": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==",
+ "dependencies": {
+ "StackExchange.Redis": "2.7.4"
+ }
+ },
+ "Azure.Monitor.OpenTelemetry.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.4.0, )",
+ "resolved": "1.4.0",
+ "contentHash": "Zs9wBCBLkm/8Fz97GfRtbuhgd4yPlM8RKxaL6owlW2KcmO8kMqjNK/2riR5DUF5ck8KloFsUg+cuGTDmIHlqww==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Azure.Monitor.OpenTelemetry.Exporter": "1.5.0",
+ "OpenTelemetry.Extensions.Hosting": "1.14.0",
+ "OpenTelemetry.Instrumentation.AspNetCore": "1.14.0",
+ "OpenTelemetry.Instrumentation.Http": "1.14.0"
+ }
+ },
+ "Dapper": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.72, )",
+ "resolved": "2.1.72",
+ "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
+ },
+ "EFCore.NamingConventions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.1, )",
+ "resolved": "10.0.1",
+ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)"
+ }
+ },
+ "FluentValidation": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.1, )",
+ "resolved": "12.1.1",
+ "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
+ },
+ "FluentValidation.DependencyInjectionExtensions": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.1, )",
+ "resolved": "12.1.1",
+ "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==",
+ "dependencies": {
+ "FluentValidation": "12.1.1"
+ }
+ },
+ "Hangfire.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.8.23, )",
+ "resolved": "1.8.23",
+ "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==",
+ "dependencies": {
+ "Hangfire.NetCore": "[1.8.23]"
+ }
+ },
+ "Hangfire.Core": {
+ "type": "CentralTransitive",
+ "requested": "[1.8.23, )",
+ "resolved": "1.8.23",
+ "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==",
+ "dependencies": {
+ "Newtonsoft.Json": "11.0.1"
+ }
+ },
+ "Hangfire.PostgreSql": {
+ "type": "CentralTransitive",
+ "requested": "[1.21.1, )",
+ "resolved": "1.21.1",
+ "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==",
+ "dependencies": {
+ "Dapper": "2.0.123",
+ "Dapper.AOT": "1.0.48",
+ "Hangfire.Core": "1.8.0",
+ "Npgsql": "6.0.11"
+ }
+ },
+ "Microsoft.AspNetCore.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "vKiAcGXG0BwNVw3bOjjWRLnp9tR18dR7MiwpvC94h0yFS+zfnzGHzS/JmmgwUdRixrGxrlIMRAWrVc+2DfAGlg==",
+ "dependencies": {
+ "Microsoft.OpenApi": "2.0.0"
+ }
+ },
+ "Microsoft.Build.Framework": {
+ "type": "CentralTransitive",
+ "requested": "[18.0.2, )",
+ "resolved": "18.0.2",
+ "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "G6yclVO5/csPzzsymV0SemY2NDqE31CP5M3jprF5IuO9wJsh4aUOfYD8HCLuDmM1D1CfReegVic48O2r79d46Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.7",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.7"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Design": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "8UMWkJdfwN/tyEZS6zd0s55zE5zaG4owldh+E91vXitHmux3FTP6Hjhgk6RL9Sv+TdO4FMERQIT6VzBtRrb1AQ==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "18.0.2",
+ "Microsoft.CodeAnalysis.CSharp": "5.0.0",
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.7",
+ "Microsoft.Extensions.DependencyModel": "10.0.7",
+ "Mono.TextTemplating": "3.0.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "midwPufIwXhOJcVhaZpCZGNbjy2QoPfHI+70nw2dGcoULEW9DybMvMPYkRjOJV0eI46a1oVFhU4lFYDEx6YUbg==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Caching.Hybrid": {
+ "type": "CentralTransitive",
+ "requested": "[10.5.0, )",
+ "resolved": "10.5.0",
+ "contentHash": "INkOmE/6q6txxCS45A9HfY8dCqqjTMJfGzr3cNoMwuZpHVSr0JhMfgr/QNm9BvtvyzsyiK+q7yhCn57fBmoy9Q=="
+ },
+ "Microsoft.Extensions.Caching.StackExchangeRedis": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "TvD0totA8qeyrZ2YeY/qRNgYBy4BvO6dG59ziJDLVnLH4s2jeLUFEFcgA3xzqPhCMMbuz9bJTRwHxkZ/7c87jQ==",
+ "dependencies": {
+ "StackExchange.Redis": "2.7.27"
+ }
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "gCglFg/9Chu3lyJNytRuQAYM3mXQKNs1i01Cz2bc545QaHQ+LbBb4O5UCfu968Gro3ZVSOZ/ktilmPcaUSGSZA=="
+ },
+ "Microsoft.Extensions.Http.Resilience": {
+ "type": "CentralTransitive",
+ "requested": "[10.5.0, )",
+ "resolved": "10.5.0",
+ "contentHash": "81rw+wjFFP5jREOERb1PHIPvBNFtE6NXO8bsLTSCET2UZWxj7cwrpzcI3l07tOpHEprYmruZAF3kZEar7uG4Iw==",
+ "dependencies": {
+ "Microsoft.Extensions.Http.Diagnostics": "10.5.0",
+ "Microsoft.Extensions.Resilience": "10.5.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols": {
+ "type": "CentralTransitive",
+ "requested": "[8.16.0, )",
+ "resolved": "8.16.0",
+ "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": {
+ "type": "CentralTransitive",
+ "requested": "[8.16.0, )",
+ "resolved": "8.16.0",
+ "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols": "8.16.0",
+ "System.IdentityModel.Tokens.Jwt": "8.16.0"
+ }
+ },
+ "Microsoft.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[2.7.3, )",
+ "resolved": "2.7.3",
+ "contentHash": "riS56czrBUgnvTmJqgXXiKUuTZisnvFfi4BzW9vGcjvTMUPsbCrdG5Nohwsx7G+pVwXgZIQOM+MnT/xgT7bisA=="
+ },
+ "Newtonsoft.Json": {
+ "type": "CentralTransitive",
+ "requested": "[13.0.4, )",
+ "resolved": "13.0.4",
+ "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
+ },
+ "Npgsql": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.2, )",
+ "resolved": "10.0.2",
+ "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg=="
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.1, )",
+ "resolved": "10.0.1",
+ "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
+ "Npgsql": "10.0.2"
+ }
+ },
+ "OpenTelemetry.Exporter.Console": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "QBGOoPwLHDXX+hXeUpspOjsqEn4vMkLw672QN+MzVWFBzjf625DdxLxhzowS1J/dRtW93U34rRbJec+4808fkg==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Extensions.Hosting": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Instrumentation.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.2, )",
+ "resolved": "1.15.2",
+ "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.14.0-beta.2, )",
+ "resolved": "1.14.0-beta.2",
+ "contentHash": "XsxsKgMuwi84TWkPN98H8FLOO/yW8vWIo/lxXQ8kWXastTI58+A4nmlFderFPmpLc+tvyhOGjHDlTK/AXWWOpQ==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.14.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Http": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.1, )",
+ "resolved": "1.15.1",
+ "contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Runtime": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.1, )",
+ "resolved": "1.15.1",
+ "contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
+ "dependencies": {
+ "OpenTelemetry.Api": "[1.15.3, 2.0.0)"
+ }
+ },
+ "Polly": {
+ "type": "CentralTransitive",
+ "requested": "[8.6.6, )",
+ "resolved": "8.6.6",
+ "contentHash": "czKHYJ6uGowPijuZt4kgF4njfGvWxVZ8mKBcrZ9iEtwDe9HKdF0ug6p6TwUG8EHuuufgbDU//rSBFebt5/0Fyw==",
+ "dependencies": {
+ "Polly.Core": "8.6.6"
+ }
+ },
+ "RabbitMQ.Client": {
+ "type": "CentralTransitive",
+ "requested": "[7.2.1, )",
+ "resolved": "7.2.1",
+ "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg=="
+ },
+ "Rebus": {
+ "type": "CentralTransitive",
+ "requested": "[8.9.2, )",
+ "resolved": "8.9.2",
+ "contentHash": "JyiO5vkH76wxLKcgXle7ewZ7rfIg+/L8/EFJY8npRsI1QwW8YprZTQX7EBbIuBqfeaqUra+2/TEPen4Nx+PU6A==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.4"
+ }
+ },
+ "Rebus.RabbitMq": {
+ "type": "CentralTransitive",
+ "requested": "[10.1.1, )",
+ "resolved": "10.1.1",
+ "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==",
+ "dependencies": {
+ "RabbitMq.Client": "7.1.2",
+ "rebus": "8.9.0"
+ }
+ },
+ "Rebus.ServiceProvider": {
+ "type": "CentralTransitive",
+ "requested": "[10.7.2, )",
+ "resolved": "10.7.2",
+ "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==",
+ "dependencies": {
+ "Rebus": "8.9.0"
+ }
+ },
+ "Scrutor": {
+ "type": "CentralTransitive",
+ "requested": "[7.0.0, )",
+ "resolved": "7.0.0",
+ "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyModel": "10.0.0"
+ }
+ },
+ "Serilog": {
+ "type": "CentralTransitive",
+ "requested": "[4.3.1, )",
+ "resolved": "4.3.1",
+ "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
+ },
+ "Serilog.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==",
+ "dependencies": {
+ "Serilog": "4.3.0",
+ "Serilog.Extensions.Hosting": "10.0.0",
+ "Serilog.Formatting.Compact": "3.0.0",
+ "Serilog.Settings.Configuration": "10.0.0",
+ "Serilog.Sinks.Console": "6.1.1",
+ "Serilog.Sinks.Debug": "3.0.0",
+ "Serilog.Sinks.File": "7.0.0"
+ }
+ },
+ "Serilog.Enrichers.Environment": {
+ "type": "CentralTransitive",
+ "requested": "[3.0.1, )",
+ "resolved": "3.0.1",
+ "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Enrichers.Process": {
+ "type": "CentralTransitive",
+ "requested": "[3.0.0, )",
+ "resolved": "3.0.0",
+ "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Enrichers.Thread": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.0, )",
+ "resolved": "4.0.0",
+ "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Settings.Configuration": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyModel": "10.0.0",
+ "Serilog": "4.3.0"
+ }
+ },
+ "Serilog.Sinks.Console": {
+ "type": "CentralTransitive",
+ "requested": "[6.1.1, )",
+ "resolved": "6.1.1",
+ "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.Seq": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==",
+ "dependencies": {
+ "Serilog": "4.2.0",
+ "Serilog.Sinks.File": "6.0.0"
+ }
+ },
+ "System.IdentityModel.Tokens.Jwt": {
+ "type": "CentralTransitive",
+ "requested": "[8.17.0, )",
+ "resolved": "8.17.0",
+ "contentHash": "nKikRYheDeSaXA3wGr2otwaiRFygBa25m+hc7MEomZVIEWZvKVqd8wgP9yn+8QpLRGgw//dUs4LErGx9gtVmAA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "8.17.0",
+ "Microsoft.IdentityModel.Tokens": "8.17.0"
+ }
+ },
+ "System.IO.Hashing": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "6hsjdSr4VOXSOnhALkYplHpAxnTG1J33YN42IB6nH2fEg4QnJqrZ4Ft+qn7mkrKAOYC8pCSFYwVWw6rQbmwgLQ=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/Bookings/Tests/packages.lock.json b/src/Modules/Bookings/Tests/packages.lock.json
index 33bdbbf7e..0982805f3 100644
--- a/src/Modules/Bookings/Tests/packages.lock.json
+++ b/src/Modules/Bookings/Tests/packages.lock.json
@@ -1030,6 +1030,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Communications/Tests/packages.lock.json b/src/Modules/Communications/Tests/packages.lock.json
index 70fba0c6f..5efb6ee75 100644
--- a/src/Modules/Communications/Tests/packages.lock.json
+++ b/src/Modules/Communications/Tests/packages.lock.json
@@ -1095,6 +1095,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Documents/Tests/packages.lock.json b/src/Modules/Documents/Tests/packages.lock.json
index 4b09fcee4..c2e1f769a 100644
--- a/src/Modules/Documents/Tests/packages.lock.json
+++ b/src/Modules/Documents/Tests/packages.lock.json
@@ -1096,6 +1096,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Locations/Tests/packages.lock.json b/src/Modules/Locations/Tests/packages.lock.json
index ec7b2b3d0..9843747af 100644
--- a/src/Modules/Locations/Tests/packages.lock.json
+++ b/src/Modules/Locations/Tests/packages.lock.json
@@ -1021,6 +1021,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Payments/Tests/packages.lock.json b/src/Modules/Payments/Tests/packages.lock.json
index a803d6878..3ccabf339 100644
--- a/src/Modules/Payments/Tests/packages.lock.json
+++ b/src/Modules/Payments/Tests/packages.lock.json
@@ -1105,6 +1105,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs
index 487107c0e..0566ff925 100644
--- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs
+++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs
@@ -38,7 +38,6 @@ Recupera dados públicos e seguros de um prestador para exibição no site.
- Dados de auditoria interna
""")
.AllowAnonymous() // Permite acesso sem login
- .RequireRateLimiting(RateLimitPolicies.Public) // Aplica política de rate limiting pública
.Produces>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
diff --git a/src/Modules/Providers/Tests/packages.lock.json b/src/Modules/Providers/Tests/packages.lock.json
index ba91c7962..a6fcdc611 100644
--- a/src/Modules/Providers/Tests/packages.lock.json
+++ b/src/Modules/Providers/Tests/packages.lock.json
@@ -1041,6 +1041,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Ratings/Tests/packages.lock.json b/src/Modules/Ratings/Tests/packages.lock.json
index a803d6878..3ccabf339 100644
--- a/src/Modules/Ratings/Tests/packages.lock.json
+++ b/src/Modules/Ratings/Tests/packages.lock.json
@@ -1105,6 +1105,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/SearchProviders/Tests/packages.lock.json b/src/Modules/SearchProviders/Tests/packages.lock.json
index aa817b541..1e69abb81 100644
--- a/src/Modules/SearchProviders/Tests/packages.lock.json
+++ b/src/Modules/SearchProviders/Tests/packages.lock.json
@@ -1032,6 +1032,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/ServiceCatalogs/Tests/packages.lock.json b/src/Modules/ServiceCatalogs/Tests/packages.lock.json
index aa817b541..1e69abb81 100644
--- a/src/Modules/ServiceCatalogs/Tests/packages.lock.json
+++ b/src/Modules/ServiceCatalogs/Tests/packages.lock.json
@@ -1032,6 +1032,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs b/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs
index b4e283457..a0683c481 100644
--- a/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs
+++ b/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs
@@ -35,7 +35,6 @@ public static void Map(IEndpointRouteBuilder app)
.WithTags("Users")
.WithSummary("Registers a new customer")
.WithDescription("Creates a new user account with 'customer' role.")
- .RequireRateLimiting(RateLimitPolicies.Registration)
.AllowAnonymous(); // Endpoint público
}
}
diff --git a/src/Modules/Users/Tests/packages.lock.json b/src/Modules/Users/Tests/packages.lock.json
index a9fe0324d..63f147c90 100644
--- a/src/Modules/Users/Tests/packages.lock.json
+++ b/src/Modules/Users/Tests/packages.lock.json
@@ -1107,6 +1107,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/src/Shared/Messaging/MessagingExtensions.cs b/src/Shared/Messaging/MessagingExtensions.cs
index edd984ca4..39955e19f 100644
--- a/src/Shared/Messaging/MessagingExtensions.cs
+++ b/src/Shared/Messaging/MessagingExtensions.cs
@@ -194,13 +194,8 @@ public static async Task EnsureMessagingInfrastructureAsync(this IHost host)
return;
}
- var entryAssembly = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
- if (entryAssembly != null && (entryAssembly.Contains("swashbuckle", StringComparison.OrdinalIgnoreCase) ||
- entryAssembly.Contains("swagger", StringComparison.OrdinalIgnoreCase)))
- {
- return;
- }
-
+ // Verificar registro do manager ANTES de qualquer early return para garantir fail-fast em cenários de DI incorretos
+ // (exceto para ambientes de testing onde Infrastructure é stubbed)
var manager = scope.ServiceProvider.GetService();
if (manager is null)
{
@@ -213,6 +208,13 @@ public static async Task EnsureMessagingInfrastructureAsync(this IHost host)
"missing the required infrastructure manager.");
}
+ var entryAssembly = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
+ if (entryAssembly != null && (entryAssembly.Contains("swashbuckle", StringComparison.OrdinalIgnoreCase) ||
+ entryAssembly.Contains("swagger", StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
var logger = scope.ServiceProvider.GetRequiredService>();
var useNewtonsoftJson = ResolveUseNewtonsoftJson(configuration);
diff --git a/src/Shared/Middleware/GeographicRestrictionMiddleware.cs b/src/Shared/Middleware/GeographicRestrictionMiddleware.cs
new file mode 100644
index 000000000..fff4fe5e6
--- /dev/null
+++ b/src/Shared/Middleware/GeographicRestrictionMiddleware.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using MeAjudaAi.Shared.Utilities.Constants;
+using MeAjudaAi.Shared.Geolocation;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.FeatureManagement;
+
+namespace MeAjudaAi.Shared.Middleware;
+
+public class GeographicRestrictionMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IOptionsMonitor options,
+ IFeatureManager featureManager)
+{
+ public async Task InvokeAsync(HttpContext context, IGeographicValidationService? geographicValidationService = null)
+ {
+ var isFeatureEnabled = await featureManager.IsEnabledAsync(FeatureFlags.GeographicRestriction);
+ var optionsValue = options.CurrentValue;
+
+ if (!isFeatureEnabled || !optionsValue.Enabled)
+ {
+ await next(context);
+ return;
+ }
+
+ var path = context.Request.Path.Value ?? string.Empty;
+ if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("/_framework", StringComparison.OrdinalIgnoreCase))
+ {
+ await next(context);
+ return;
+ }
+
+ var (city, state) = ExtractLocation(context);
+
+ var isAllowed = await IsLocationAllowedAsync(city, state, geographicValidationService, context.RequestAborted);
+ if (!isAllowed)
+ {
+ logger.LogWarning(
+ "Geographic restriction: Request blocked from {City}/{State}. IP: {IpAddress}",
+ city ?? "Unknown",
+ state ?? "Unknown",
+ context.Connection.RemoteIpAddress);
+
+ context.Response.StatusCode = 451;
+ context.Response.ContentType = "application/json";
+
+ var allowedRegions = GetAllowedRegionsDescription();
+ var blockedMessage = options.CurrentValue.BlockedMessage ?? options.CurrentValue.DefaultBlockedMessage;
+ var template = blockedMessage ?? "Acesso da sua região não permitido. Regiões permitidas: {allowedRegions}.";
+ var message = template.Replace("{allowedRegions}", allowedRegions);
+
+ var errorResponse = new GeographicRestrictionErrorResponse(
+ message: message,
+ userLocation: UserLocation.Create(city, state),
+ allowedCities: options.CurrentValue.AllowedCities?.Select(cityConfig =>
+ {
+ var parts = cityConfig.Split('|', 2);
+ return AllowedCity.Create(parts[0].Trim(), parts.Length > 1 ? parts[1].Trim() : null);
+ }),
+ allowedStates: options.CurrentValue.AllowedStates);
+
+ await context.Response.WriteAsync(
+ JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true
+ }));
+
+ return;
+ }
+
+ await next(context);
+ }
+
+ private static (string? City, string? State) ExtractLocation(HttpContext context)
+ {
+ if (context.Request.Headers.TryGetValue("X-User-Location", out var locationHeader))
+ {
+ var headerSpan = locationHeader.ToString().AsSpan();
+ var separatorIndex = headerSpan.IndexOf('|');
+
+ if (separatorIndex >= 0)
+ {
+ var remainder = headerSpan[(separatorIndex + 1)..];
+ if (remainder.IndexOf('|') >= 0) return (string.Empty, string.Empty);
+
+ var locationCity = headerSpan[..separatorIndex].Trim().ToString();
+ var locationState = remainder.Trim().ToString();
+
+ if (!string.IsNullOrWhiteSpace(locationCity) && !string.IsNullOrWhiteSpace(locationState))
+ {
+ return (locationCity, locationState);
+ }
+ }
+ return (string.Empty, string.Empty);
+ }
+
+ var cityPresent = context.Request.Headers.TryGetValue("X-User-City", out var cityHeader);
+ var statePresent = context.Request.Headers.TryGetValue("X-User-State", out var stateHeader);
+
+ if (cityPresent || statePresent)
+ {
+ var city = cityPresent ? (string.IsNullOrWhiteSpace(cityHeader.ToString()) ? string.Empty : cityHeader.ToString().Trim()) : null;
+ var state = statePresent ? (string.IsNullOrWhiteSpace(stateHeader.ToString()) ? string.Empty : stateHeader.ToString().Trim()) : null;
+ return (city, state);
+ }
+
+ return (null, null);
+ }
+
+ private async Task IsLocationAllowedAsync(string? city, string? state, IGeographicValidationService? geographicValidationService, CancellationToken cancellationToken)
+ {
+ if (city?.Length == 0 || state?.Length == 0)
+ {
+ logger.LogWarning("Geographic restriction: Malformed or empty location header detected, rejecting request.");
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(city) && string.IsNullOrEmpty(state))
+ {
+ var failOpen = options.CurrentValue.FailOpen;
+ if (failOpen)
+ {
+ logger.LogWarning("Geographic restriction: Could not determine user location, allowing access (FailOpen=true)");
+ return true;
+ }
+ logger.LogError("Geographic restriction: Could not determine user location, rejecting request (FailOpen=false)");
+ return false;
+ }
+
+ var simpleValidation = ValidateLocationSimple(city, state);
+
+ if (simpleValidation)
+ {
+ return true;
+ }
+
+ if (geographicValidationService is not null && !string.IsNullOrEmpty(city))
+ {
+ try
+ {
+ var ibgeValidation = await geographicValidationService.ValidateCityAsync(city, state, cancellationToken);
+ return ibgeValidation;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error validating with IBGE, falling back to simple validation");
+ }
+ }
+
+ return simpleValidation;
+ }
+
+ private bool ValidateLocationSimple(string? city, string? state)
+ {
+ var allowedCities = options.CurrentValue.AllowedCities;
+ var allowedStates = options.CurrentValue.AllowedStates;
+ var hasAllowedCities = allowedCities?.Any() ?? false;
+ var hasAllowedStates = allowedStates?.Any() ?? false;
+
+ if (!hasAllowedCities && !hasAllowedStates)
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrEmpty(city))
+ {
+ if (!hasAllowedCities && !hasAllowedStates) return false;
+
+ if (!hasAllowedCities)
+ {
+ if (hasAllowedStates && !string.IsNullOrEmpty(state) &&
+ allowedStates?.Any(s => s.Equals(state, StringComparison.OrdinalIgnoreCase)) == true)
+ {
+ return true;
+ }
+ return false;
+ }
+
+ foreach (var allowedCity in allowedCities ?? [])
+ {
+ var citySpan = allowedCity.AsSpan();
+ var separatorIndex = citySpan.IndexOf('|');
+
+ if (separatorIndex < 0)
+ {
+ var configCityOnly = citySpan.Trim().ToString();
+ if (!string.IsNullOrEmpty(configCityOnly) &&
+ configCityOnly.Equals(city, StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(state)) return true;
+ if (allowedStates is null || !allowedStates.Any())
+ {
+ return true;
+ }
+ return allowedStates.Any(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
+ }
+ continue;
+ }
+
+ var configCity = citySpan[..separatorIndex].Trim().ToString();
+ var configState = citySpan[(separatorIndex + 1)..].Trim().ToString();
+
+ if (configCity.Equals(city, StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(configState))
+ {
+ return true;
+ }
+ if (!string.IsNullOrEmpty(state))
+ {
+ if (configState.Equals(state, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ continue;
+ }
+ continue;
+ }
+ }
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(state))
+ {
+ if (!hasAllowedStates) return false;
+ return options.CurrentValue.AllowedStates.Any(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
+ }
+
+ return false;
+ }
+
+ private string GetAllowedRegionsDescription()
+ {
+ var cities = options.CurrentValue.AllowedCities?.Any() == true ? string.Join(", ", options.CurrentValue.AllowedCities) : "Nenhuma";
+ var states = options.CurrentValue.AllowedStates?.Any() == true ? string.Join(", ", options.CurrentValue.AllowedStates) : "Nenhum";
+ return $"Cidades: {cities} | Estados: {states}";
+ }
+}
+
+public class GeographicRestrictionOptions
+{
+ public const string SectionName = "GeographicRestriction";
+
+ public bool Enabled { get; set; } = false;
+ public List AllowedStates { get; set; } = [];
+ public List AllowedCities { get; set; } = [];
+ public string? BlockedMessage { get; set; }
+ public string? DefaultBlockedMessage { get; set; }
+ public bool FailOpen { get; set; } = true;
+}
+
+public record GeographicRestrictionErrorResponse(
+ string message,
+ UserLocation? userLocation,
+ IEnumerable? allowedCities,
+ List? allowedStates);
+
+public record AllowedCity(string Name, string? State)
+{
+ public static AllowedCity Create(string name, string? state = null) => new(name, state);
+}
+
+public record UserLocation(string? City, string? State)
+{
+ public static UserLocation Create(string? city, string? state) => new(city, state);
+}
\ No newline at end of file
diff --git a/src/Shared/Middleware/RateLimitingMiddleware.cs b/src/Shared/Middleware/RateLimitingMiddleware.cs
new file mode 100644
index 000000000..9da211b34
--- /dev/null
+++ b/src/Shared/Middleware/RateLimitingMiddleware.cs
@@ -0,0 +1,134 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace MeAjudaAi.Shared.Middleware;
+
+public class RateLimitingMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IOptionsMonitor options,
+ IMemoryCache cache)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var currentOptions = options.CurrentValue;
+ if (!currentOptions.General.Enabled)
+ {
+ await next(context);
+ return;
+ }
+
+ var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ if (currentOptions.General.EnableIpWhitelist && currentOptions.General.WhitelistedIps.Contains(clientIp))
+ {
+ await next(context);
+ return;
+ }
+
+ var isAuthenticated = context.User.Identity?.IsAuthenticated ?? false;
+ int requestsPerMinute, requestsPerHour, requestsPerDay;
+
+ if (isAuthenticated)
+ {
+ requestsPerMinute = currentOptions.Authenticated.RequestsPerMinute;
+ requestsPerHour = currentOptions.Authenticated.RequestsPerHour;
+ requestsPerDay = currentOptions.Authenticated.RequestsPerDay;
+ }
+ else
+ {
+ requestsPerMinute = currentOptions.Anonymous.RequestsPerMinute;
+ requestsPerHour = currentOptions.Anonymous.RequestsPerHour;
+ requestsPerDay = currentOptions.Anonymous.RequestsPerDay;
+ }
+
+ var windowSeconds = Math.Max(1, currentOptions.General.WindowInSeconds);
+ var window = TimeSpan.FromSeconds(windowSeconds);
+
+ var windowKey = $"rate_limit_{clientIp}_{isAuthenticated}";
+ var counter = cache.GetOrCreate(windowKey, entry =>
+ {
+ entry.AbsoluteExpirationRelativeToNow = window;
+ return new RateLimitCounter();
+ });
+
+ var currentCount = counter.IncrementAndGet();
+
+ var scaledLimit = CalculateScaledLimit(requestsPerMinute, requestsPerHour, requestsPerDay, windowSeconds);
+ if (currentCount > scaledLimit)
+ {
+ logger.LogWarning(
+ "Rate limit exceeded for {ClientIp} (Authenticated: {IsAuthenticated}). Count: {Count}, Limit: {Limit}",
+ clientIp, isAuthenticated, currentCount, scaledLimit);
+
+ context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
+ context.Response.ContentType = "application/json";
+ context.Response.Headers.Append("Retry-After", windowSeconds.ToString());
+
+ var errorResponse = new
+ {
+ error = "RateLimitExceeded",
+ message = currentOptions.General.ErrorMessage,
+ retryAfterSeconds = windowSeconds
+ };
+
+ await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse));
+ return;
+ }
+
+ await next(context);
+ }
+
+ private static int CalculateScaledLimit(int perMinute, int perHour, int perDay, int windowSeconds)
+ {
+ var candidates = new List();
+ if (perMinute > 0) candidates.Add(perMinute * windowSeconds / 60.0);
+ if (perHour > 0) candidates.Add(perHour * windowSeconds / 3600.0);
+ if (perDay > 0) candidates.Add(perDay * windowSeconds / 86400.0);
+
+ return candidates.Count > 0 ? Math.Max(1, (int)Math.Floor(candidates.Min())) : 1;
+ }
+}
+
+public class RateLimitCounter
+{
+ private int _value;
+
+ public int Value => _value;
+
+ public int IncrementAndGet() => Interlocked.Increment(ref _value);
+}
+
+public class RateLimitingOptions
+{
+ public const string SectionName = "RateLimiting";
+
+ public GeneralSettings General { get; set; } = new();
+ public AnonymousLimits Anonymous { get; set; } = new();
+ public AuthenticatedLimits Authenticated { get; set; } = new();
+}
+
+public class GeneralSettings
+{
+ public bool Enabled { get; set; } = true;
+ public int WindowInSeconds { get; set; } = 60;
+ public bool EnableIpWhitelist { get; set; } = false;
+ public List WhitelistedIps { get; set; } = [];
+ public string ErrorMessage { get; set; } = "Limite de requisições excedido. Tente novamente mais tarde.";
+}
+
+public class AnonymousLimits
+{
+ public int RequestsPerMinute { get; set; } = 30;
+ public int RequestsPerHour { get; set; } = 300;
+ public int RequestsPerDay { get; set; } = 1000;
+}
+
+public class AuthenticatedLimits
+{
+ public int RequestsPerMinute { get; set; } = 120;
+ public int RequestsPerHour { get; set; } = 2000;
+ public int RequestsPerDay { get; set; } = 10000;
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Extensions/SecurityExtensionsTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Extensions/SecurityExtensionsTests.cs
index 2db13495f..10fbf1f35 100644
--- a/tests/MeAjudaAi.ApiService.Tests/Unit/Extensions/SecurityExtensionsTests.cs
+++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Extensions/SecurityExtensionsTests.cs
@@ -1,31 +1,17 @@
using FluentAssertions;
using MeAjudaAi.ApiService.Extensions;
-using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.ApiService.Options.RateLimit;
-using MeAjudaAi.ApiService.Services.HostedServices;
-using MeAjudaAi.Modules.Users.Infrastructure.Identity.Keycloak;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
using Moq;
namespace MeAjudaAi.ApiService.Tests.Unit.Extensions;
-///
-/// Testes para SecurityExtensions - configuração de autenticação, autorização e CORS.
-///
[Trait("Category", "Unit")]
+[Trait("Layer", "ApiService")]
public class SecurityExtensionsTests
{
- private static IWebHostEnvironment CreateMockEnvironment(string environmentName = "Development")
- {
- var mock = new Mock();
- mock.Setup(e => e.EnvironmentName).Returns(environmentName);
- return mock.Object;
- }
+ private static IServiceCollection CreateServices() => new ServiceCollection();
private static IConfiguration CreateConfiguration(Dictionary settings)
{
@@ -34,440 +20,19 @@ private static IConfiguration CreateConfiguration(Dictionary se
.Build();
}
- #region ValidateSecurityConfiguration Tests
-
- [Fact]
- public void ValidateSecurityConfiguration_WithNullConfiguration_ShouldThrowArgumentNullException()
- {
- // Arrange
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(null!, environment);
- action.Should().Throw();
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_WithNullEnvironment_ShouldThrowArgumentNullException()
- {
- // Arrange
- var configuration = CreateConfiguration(new Dictionary());
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, null!);
- action.Should().Throw();
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InDevelopment_WithWildcardCors_ShouldNotThrow()
- {
- // Arrange - Development allows wildcards but still needs valid Keycloak config
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "*",
- ["Cors:AllowedMethods:0"] = "*",
- ["Cors:AllowedHeaders:0"] = "*",
- ["Cors:AllowCredentials"] = "false",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "dev-realm",
- ["Keycloak:ClientId"] = "dev-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Development");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().NotThrow();
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithWildcardCors_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "*",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowCredentials"] = "false",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Wildcard CORS origin*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithHttpOrigins_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "http://localhost:3000",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowCredentials"] = "false",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*HTTP origins*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithManyOriginsAndCredentials_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app1.com",
- ["Cors:AllowedOrigins:1"] = "https://app2.com",
- ["Cors:AllowedOrigins:2"] = "https://app3.com",
- ["Cors:AllowedOrigins:3"] = "https://app4.com",
- ["Cors:AllowedOrigins:4"] = "https://app5.com",
- ["Cors:AllowedOrigins:5"] = "https://app6.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowCredentials"] = "true",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Too many allowed origins*credentials*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithoutHttpsMetadata_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["Keycloak:RequireHttpsMetadata"] = "false"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*RequireHttpsMetadata*true*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithHttpKeycloakUrl_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Keycloak:BaseUrl"] = "http://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Keycloak BaseUrl*HTTPS*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithHighClockSkew_ShouldThrowInvalidOperationException()
- {
- // Arrange - Complete production config with clock skew exceeding 30 minute limit
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowCredentials"] = "false",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["Keycloak:RequireHttpsMetadata"] = "true",
- ["Keycloak:ClockSkew"] = "00:35:00", // ISSUE: > 30 minutes limit
- ["HttpsRedirection:Enabled"] = "true",
- ["AllowedHosts"] = "app.com"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*ClockSkew*exceed 30 minutes*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithValidHttpsRedirectionDisabled_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["HttpsRedirection:Enabled"] = "false"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*HTTPS redirection*enabled*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithWildcardAllowedHosts_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["AllowedHosts"] = "*"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*AllowedHosts*restricted*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InTesting_ShouldSkipKeycloakValidation()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "*",
- ["Cors:AllowedMethods:0"] = "*",
- ["Cors:AllowedHeaders:0"] = "*",
- ["Cors:AllowCredentials"] = "false"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Testing");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().NotThrow();
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_WithNegativeAnonymousLimits_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["AdvancedRateLimit:Anonymous:RequestsPerMinute"] = "-1",
- ["AdvancedRateLimit:Anonymous:RequestsPerHour"] = "100"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Development");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Anonymous*must be positive*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_InProduction_WithHighAnonymousLimits_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["AdvancedRateLimit:Anonymous:RequestsPerMinute"] = "200",
- ["AdvancedRateLimit:Anonymous:RequestsPerHour"] = "10000"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Anonymous request limits*conservative*production*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_WithNegativeAuthenticatedLimits_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["AdvancedRateLimit:Authenticated:RequestsPerMinute"] = "100",
- ["AdvancedRateLimit:Authenticated:RequestsPerHour"] = "-500"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Development");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Authenticated*must be positive*");
- }
-
- [Fact]
- public void ValidateSecurityConfiguration_WithValidProductionConfig_ShouldNotThrow()
- {
- // Arrange
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedOrigins:1"] = "https://admin.app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedMethods:1"] = "POST",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowedHeaders:1"] = "Authorization",
- ["Cors:AllowCredentials"] = "true",
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["Keycloak:RequireHttpsMetadata"] = "true",
- ["Keycloak:ClockSkew"] = "00:02:00",
- ["HttpsRedirection:Enabled"] = "true",
- ["AllowedHosts"] = "app.com;admin.app.com",
- ["AdvancedRateLimit:Anonymous:RequestsPerMinute"] = "50",
- ["AdvancedRateLimit:Anonymous:RequestsPerHour"] = "1000",
- ["AdvancedRateLimit:Authenticated:RequestsPerMinute"] = "200",
- ["AdvancedRateLimit:Authenticated:RequestsPerHour"] = "5000"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment("Production");
-
- // Act & Assert
- var action = () => SecurityExtensions.ValidateSecurityConfiguration(configuration, environment);
- action.Should().NotThrow();
- }
-
- #endregion
-
- #region AddCorsPolicy Tests
-
- [Fact]
- public void AddCorsPolicy_WithNullServices_ShouldThrowArgumentNullException()
- {
- // Arrange
- var configuration = CreateConfiguration(new Dictionary());
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => SecurityExtensions.AddCorsPolicy(null!, configuration, environment);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddCorsPolicy_WithNullConfiguration_ShouldThrowArgumentNullException()
- {
- // Arrange
- var services = new ServiceCollection();
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => services.AddCorsPolicy(null!, environment);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddCorsPolicy_WithNullEnvironment_ShouldThrowArgumentNullException()
- {
- // Arrange
- var services = new ServiceCollection();
- var configuration = CreateConfiguration(new Dictionary());
-
- // Act & Assert
- var action = () => services.AddCorsPolicy(configuration, null!);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddCorsPolicy_WithValidConfig_ShouldRegisterCorsOptions()
+ private static IWebHostEnvironment CreateMockEnvironment(string environmentName = "Development")
{
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging(); // Required for options validation
- var settings = new Dictionary
- {
- ["Cors:AllowedOrigins:0"] = "https://app.com",
- ["Cors:AllowedMethods:0"] = "GET",
- ["Cors:AllowedHeaders:0"] = "Content-Type",
- ["Cors:AllowCredentials"] = "false"
- };
- var configuration = CreateConfiguration(settings);
- services.AddSingleton(configuration); // Required for options configuration
- var environment = CreateMockEnvironment();
-
- // Act
- services.AddCorsPolicy(configuration, environment);
-
- // Assert
- var provider = services.BuildServiceProvider();
- var corsOptions = provider.GetService>();
- corsOptions.Should().NotBeNull();
+ var mock = new Mock();
+ mock.Setup(e => e.EnvironmentName).Returns(environmentName);
+ return mock.Object;
}
- #endregion
-
- #region AddEnvironmentAuthentication Tests
-
[Fact]
public void AddEnvironmentAuthentication_WithNullServices_ShouldThrowArgumentNullException()
{
- // Arrange
var configuration = CreateConfiguration(new Dictionary());
var environment = CreateMockEnvironment();
- // Act & Assert
var action = () => SecurityExtensions.AddEnvironmentAuthentication(null!, configuration, environment);
action.Should().Throw();
}
@@ -475,11 +40,9 @@ public void AddEnvironmentAuthentication_WithNullServices_ShouldThrowArgumentNul
[Fact]
public void AddEnvironmentAuthentication_WithNullConfiguration_ShouldThrowArgumentNullException()
{
- // Arrange
- var services = new ServiceCollection();
+ var services = CreateServices();
var environment = CreateMockEnvironment();
- // Act & Assert
var action = () => services.AddEnvironmentAuthentication(null!, environment);
action.Should().Throw();
}
@@ -487,306 +50,92 @@ public void AddEnvironmentAuthentication_WithNullConfiguration_ShouldThrowArgume
[Fact]
public void AddEnvironmentAuthentication_WithNullEnvironment_ShouldThrowArgumentNullException()
{
- // Arrange
- var services = new ServiceCollection();
+ var services = CreateServices();
var configuration = CreateConfiguration(new Dictionary());
- // Act & Assert
var action = () => services.AddEnvironmentAuthentication(configuration, null!);
action.Should().Throw();
}
[Fact]
- public void AddEnvironmentAuthentication_InTesting_ShouldNotAddKeycloak()
- {
- // Arrange
- var services = new ServiceCollection();
- var configuration = CreateConfiguration(new Dictionary());
- var environment = CreateMockEnvironment("Testing");
-
- // Act
- services.AddEnvironmentAuthentication(configuration, environment);
-
- // Assert - Should not throw even without Keycloak config
- var action = () => services.BuildServiceProvider();
- action.Should().NotThrow();
- }
-
- #endregion
-
- #region AddKeycloakAuthentication Tests
-
- [Fact]
- public void AddKeycloakAuthentication_WithNullServices_ShouldThrowArgumentNullException()
- {
- // Arrange
- var configuration = CreateConfiguration(new Dictionary());
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => SecurityExtensions.AddKeycloakAuthentication(null!, configuration, environment);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithNullConfiguration_ShouldThrowArgumentNullException()
- {
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(null!, environment);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithNullEnvironment_ShouldThrowArgumentNullException()
- {
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var configuration = CreateConfiguration(new Dictionary
- {
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- });
-
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, null!);
- action.Should().Throw();
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithMissingBaseUrl_ShouldThrowInvalidOperationException()
+ public void AddEnvironmentAuthentication_WithValidConfig_ShouldNotThrow()
{
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var settings = new Dictionary
- {
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Keycloak BaseUrl*required*");
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithMissingRealm_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
+ var services = CreateServices();
var settings = new Dictionary
{
["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Keycloak Realm*required*");
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithMissingClientId_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var settings = new Dictionary
- {
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm"
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment();
-
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, environment);
- action.Should().Throw()
- .WithMessage("*Keycloak ClientId*required*");
- }
-
- [Fact]
- public void AddKeycloakAuthentication_WithInvalidBaseUrl_ShouldThrowInvalidOperationException()
- {
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var settings = new Dictionary
- {
- ["Keycloak:BaseUrl"] = "not-a-valid-url",
["Keycloak:Realm"] = "test-realm",
["Keycloak:ClientId"] = "test-client"
};
var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment();
+ var environment = CreateMockEnvironment("Development");
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, environment);
- action.Should().Throw()
- .WithMessage("*not a valid URL*");
+ var action = () => services.AddEnvironmentAuthentication(configuration, environment);
+ action.Should().NotThrow();
}
[Fact]
- public void AddKeycloakAuthentication_WithExcessiveClockSkew_ShouldThrowInvalidOperationException()
+ public void AddKeycloakAuthentication_WithNullServices_ShouldThrowArgumentNullException()
{
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- var settings = new Dictionary
- {
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client",
- ["Keycloak:ClockSkew"] = "00:35:00" // 35 minutes in TimeSpan format - > 30 minute limit
- };
- var configuration = CreateConfiguration(settings);
- var environment = CreateMockEnvironment();
+ var configuration = CreateConfiguration(new Dictionary());
+ var environment = CreateMockEnvironment("Development");
- // Act & Assert
- var action = () => services.AddKeycloakAuthentication(configuration, environment);
- action.Should().Throw()
- .WithMessage("*ClockSkew*exceed 30 minutes*");
+ var action = () => SecurityExtensions.AddKeycloakAuthentication(null!, configuration, environment);
+ action.Should().Throw();
}
[Fact]
- public void AddKeycloakAuthentication_WithValidConfig_ShouldRegisterAuthentication()
+ public void AddKeycloakAuthentication_WithValidConfig_ShouldRegisterServices()
{
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
+ var services = CreateServices();
var settings = new Dictionary
{
["Keycloak:BaseUrl"] = "https://keycloak.example.com",
["Keycloak:Realm"] = "test-realm",
["Keycloak:ClientId"] = "test-client",
- ["Keycloak:RequireHttpsMetadata"] = "true",
- ["Keycloak:ValidateIssuer"] = "true",
- ["Keycloak:ValidateAudience"] = "true",
- ["Keycloak:ClockSkew"] = "00:05:00"
+ ["Keycloak:RequireHttpsMetadata"] = "true"
};
var configuration = CreateConfiguration(settings);
- services.AddSingleton(configuration); // Required for options configuration
- var environment = CreateMockEnvironment();
-
- // Act
- services.AddKeycloakAuthentication(configuration, environment);
+ var environment = CreateMockEnvironment("Development");
- // Assert
- var provider = services.BuildServiceProvider();
- var keycloakOptions = provider.GetService>();
- keycloakOptions.Should().NotBeNull();
- keycloakOptions!.Value.ClientId.Should().Be("test-client");
+ var action = () => services.AddKeycloakAuthentication(configuration, environment);
+ action.Should().NotThrow();
}
- #endregion
-
- #region AddAuthorizationPolicies Tests
-
[Fact]
public void AddAuthorizationPolicies_WithNullServices_ShouldThrowArgumentNullException()
{
- // Arrange
var configuration = CreateConfiguration(new Dictionary());
+ var environment = CreateMockEnvironment();
- // Act & Assert
- var action = () => SecurityExtensions.AddAuthorizationPolicies(null!, configuration);
+ var action = () => SecurityExtensions.AddAuthorizationPolicies(null!, configuration, environment);
action.Should().Throw();
}
[Fact]
- public void AddAuthorizationPolicies_ShouldRegisterAuthorizationPolicies()
+ public void AddAuthorizationPolicies_WithValidConfig_ShouldRegisterServices()
{
- // Arrange
- var services = new ServiceCollection();
- services.AddLogging();
- services.AddAuthorization();
- var settings = new Dictionary
- {
- ["Keycloak:BaseUrl"] = "https://keycloak.example.com",
- ["Keycloak:Realm"] = "test-realm",
- ["Keycloak:ClientId"] = "test-client"
- };
- var configuration = CreateConfiguration(settings);
- services.AddSingleton(configuration);
- services.AddHttpClient(); // Required for KeycloakPermissionResolver
-
- // Act
- services.AddAuthorizationPolicies(configuration);
+ var services = CreateServices();
+ var configuration = CreateConfiguration(new Dictionary());
+ var environment = CreateMockEnvironment("Development");
- // Assert
- var provider = services.BuildServiceProvider();
- var authorizationOptions = provider.GetService>();
- authorizationOptions.Should().NotBeNull();
- authorizationOptions!.Value.GetPolicy("SelfOrAdmin").Should().NotBeNull();
- authorizationOptions.Value.GetPolicy("AdminOnly").Should().NotBeNull();
- authorizationOptions.Value.GetPolicy("SuperAdminOnly").Should().NotBeNull();
+ var action = () => services.AddAuthorizationPolicies(configuration, environment);
+ action.Should().NotThrow();
}
- #endregion
-
- #region KeycloakConfigurationLogger Tests
-
[Fact]
- public async Task KeycloakConfigurationLogger_StartAsync_ShouldLogConfiguration()
+ public void AddCustomAntiforgery_WithNullServices_ShouldThrowArgumentNullException()
{
- // Arrange
- var keycloakOptions = Microsoft.Extensions.Options.Options.Create(new KeycloakOptions
- {
- BaseUrl = "https://keycloak.example.com",
- Realm = "test-realm",
- ClientId = "test-client"
- });
-
- var loggerMock = new Mock>();
- var loggerInstance = new KeycloakConfigurationLogger(keycloakOptions, loggerMock.Object);
-
- // Act
- await loggerInstance.StartAsync(CancellationToken.None);
-
- // Assert
- loggerMock.Verify(
- l => l.Log(
- LogLevel.Information,
- It.IsAny(),
- It.Is((v, t) => v.ToString()!.Contains("Keycloak")),
- null,
- It.IsAny>()),
- Times.AtLeastOnce);
+ var action = () => SecurityExtensions.AddCustomAntiforgery(null!);
+ action.Should().Throw();
}
[Fact]
- public async Task KeycloakConfigurationLogger_StopAsync_ShouldCompleteSuccessfully()
+ public void AddCustomAntiforgery_ShouldRegisterAntiforgeryServices()
{
- // Arrange
- var keycloakOptions = Microsoft.Extensions.Options.Options.Create(new KeycloakOptions
- {
- BaseUrl = "https://keycloak.example.com",
- Realm = "test-realm",
- ClientId = "test-client"
- });
-
- var loggerMock = new Mock>();
- var loggerInstance = new KeycloakConfigurationLogger(keycloakOptions, loggerMock.Object);
+ var services = CreateServices();
- // Act & Assert - Should complete without exceptions
- var act = () => loggerInstance.StopAsync(CancellationToken.None);
- await act.Should().NotThrowAsync();
+ var action = () => services.AddCustomAntiforgery();
+ action.Should().NotThrow();
}
-
- #endregion
-}
-
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/GeographicRestrictionMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/GeographicRestrictionMiddlewareTests.cs
deleted file mode 100644
index a481949c5..000000000
--- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/GeographicRestrictionMiddlewareTests.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using MeAjudaAi.ApiService.Middlewares;
-using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.Shared.Geolocation;
-using MeAjudaAi.Shared.Utilities.Constants;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Microsoft.FeatureManagement;
-using Moq;
-using FluentAssertions;
-using Xunit;
-
-namespace MeAjudaAi.ApiService.Tests.Unit.Middlewares;
-
-public class GeographicRestrictionMiddlewareTests
-{
- private readonly Mock> _loggerMock = new();
- private readonly Mock _featureManagerMock = new();
- private readonly Mock> _optionsMock = new();
- private readonly Mock _geoServiceMock = new();
- private readonly GeographicRestrictionOptions _options = new();
-
- public GeographicRestrictionMiddlewareTests()
- {
- _optionsMock.Setup(o => o.CurrentValue).Returns(_options);
- _featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.GeographicRestriction)).ReturnsAsync(true);
- }
-
- [Fact]
- public async Task InvokeAsync_WhenFeatureDisabled_ShouldCallNext()
- {
- _featureManagerMock.Setup(f => f.IsEnabledAsync(FeatureFlags.GeographicRestriction)).ReturnsAsync(false);
- var nextCalled = false;
- RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
- var sut = new GeographicRestrictionMiddleware(next, _loggerMock.Object, _optionsMock.Object, _featureManagerMock.Object);
- var context = new DefaultHttpContext();
- await sut.InvokeAsync(context);
- nextCalled.Should().BeTrue();
- }
-
- [Fact]
- public async Task InvokeAsync_WhenHeaderHasMultipleSeparators_ShouldReject()
- {
- // Arrange
- var context = new DefaultHttpContext();
- context.Request.Headers["X-User-Location"] = "City|State|Extra";
-
- var nextCalled = false;
- RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
- var sut = new GeographicRestrictionMiddleware(next, _loggerMock.Object, _optionsMock.Object, _featureManagerMock.Object);
-
- // Act
- await sut.InvokeAsync(context);
-
- // Assert
- context.Response.StatusCode.Should().Be(451);
- nextCalled.Should().BeFalse();
- }
-}
diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/RateLimitingMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/RateLimitingMiddlewareTests.cs
deleted file mode 100644
index e358f62a2..000000000
--- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/RateLimitingMiddlewareTests.cs
+++ /dev/null
@@ -1,460 +0,0 @@
-using System.Net;
-using System.Security.Claims;
-using System.Text.Json;
-using FluentAssertions;
-using MeAjudaAi.ApiService.Middlewares;
-using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.ApiService.Options.RateLimit;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Moq;
-using Xunit;
-
-namespace MeAjudaAi.ApiService.Tests.Unit.Middlewares;
-
-public class RateLimitingMiddlewareTests
-{
- private readonly IMemoryCache _cache;
- private readonly Mock> _loggerMock;
- private readonly Mock> _optionsMock;
- private bool _nextCalled;
-
- public RateLimitingMiddlewareTests()
- {
- _cache = new MemoryCache(new MemoryCacheOptions());
- _loggerMock = new Mock>();
- _optionsMock = new Mock>();
- _nextCalled = false;
- }
-
- [Fact]
- public async Task InvokeAsync_WhenRateLimitingDisabled_ShouldBypass()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.Enabled = false;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext();
-
- // Act
- await middleware.InvokeAsync(context);
-
- // Assert
- _nextCalled.Should().BeTrue();
- context.Response.StatusCode.Should().Be(200);
- }
-
- [Fact]
- public async Task InvokeAsync_WhenIpIsWhitelisted_ShouldBypass()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.EnableIpWhitelist = true;
- options.General.WhitelistedIps = ["127.0.0.1"];
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext(remoteIp: "127.0.0.1");
-
- // Act
- await middleware.InvokeAsync(context);
-
- // Assert
- _nextCalled.Should().BeTrue();
- context.Response.StatusCode.Should().Be(200);
- }
-
- [Fact]
- public async Task InvokeAsync_WhenLimitNotExceeded_ShouldAllowRequest()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 10;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext();
-
- // Act
- await middleware.InvokeAsync(context);
-
- // Assert
- _nextCalled.Should().BeTrue();
- context.Response.StatusCode.Should().Be(200);
- }
-
- [Fact]
- public async Task InvokeAsync_WhenLimitExceeded_ShouldReturn429()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 2;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext();
-
- // Act - Make 3 requests (limit is 2)
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(); // Reset context
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(); // Reset context
- await middleware.InvokeAsync(context);
-
- // Assert
- context.Response.StatusCode.Should().Be(429);
- context.Response.Headers.Should().ContainKey("Retry-After");
-
- var responseBody = await ReadResponseBody(context);
- responseBody.Should().Contain("RateLimitExceeded");
- }
-
- [Fact]
- public async Task InvokeAsync_WhenApproachingLimit_ShouldLogInformation()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 10;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext();
-
- // Act - Make 8 requests (80% of 10)
- for (int i = 0; i < 8; i++)
- {
- context = CreateHttpContext();
- await middleware.InvokeAsync(context);
- }
-
- // Assert - Should log information about approaching limit
- _loggerMock.Verify(
- x => x.Log(
- LogLevel.Information,
- It.IsAny(),
- It.Is((v, t) => v.ToString()!.Contains("approaching rate limit")),
- null,
- It.IsAny>()),
- Times.AtLeastOnce);
- }
-
- [Fact]
- public async Task InvokeAsync_AuthenticatedUser_ShouldUseAuthenticatedLimits()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 2;
- options.Authenticated.RequestsPerMinute = 100;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext(isAuthenticated: true);
-
- // Act - Make 3 requests (would exceed anonymous limit but not authenticated)
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(isAuthenticated: true);
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(isAuthenticated: true);
- await middleware.InvokeAsync(context);
-
- // Assert
- _nextCalled.Should().BeTrue();
- context.Response.StatusCode.Should().Be(200);
- }
-
- [Fact]
- public async Task InvokeAsync_EndpointSpecificLimit_ShouldApplyCorrectLimit()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 100;
- options.EndpointLimits = new Dictionary
- {
- ["api_search"] = new EndpointLimits
- {
- Pattern = "/api/search*",
- RequestsPerMinute = 2,
- RequestsPerHour = 100,
- ApplyToAnonymous = true,
- ApplyToAuthenticated = true
- }
- };
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext(path: "/api/search");
-
- // Act - Make 3 requests to /api/search
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(path: "/api/search");
- await middleware.InvokeAsync(context);
- context = CreateHttpContext(path: "/api/search");
- await middleware.InvokeAsync(context);
-
- // Assert
- context.Response.StatusCode.Should().Be(429);
- }
-
- [Fact]
- public async Task InvokeAsync_RoleBasedLimit_ShouldApplyRoleLimit()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Authenticated.RequestsPerMinute = 10;
- options.RoleLimits = new Dictionary
- {
- ["premium"] = new RoleLimits
- {
- RequestsPerMinute = 1000,
- RequestsPerHour = 50000,
- RequestsPerDay = 500000
- }
- };
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext(isAuthenticated: true, roles: ["premium"]);
-
- // Act - Make 20 requests (would exceed default auth limit but not premium)
- for (int i = 0; i < 20; i++)
- {
- context = CreateHttpContext(isAuthenticated: true, roles: ["premium"]);
- await middleware.InvokeAsync(context);
- }
-
- // Assert
- _nextCalled.Should().BeTrue();
- context.Response.StatusCode.Should().Be(200);
- }
-
- [Fact]
- public async Task InvokeAsync_DifferentPaths_ShouldHaveSeparateCounters()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 2;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
-
- // Act
- var context1 = CreateHttpContext(path: "/api/users");
- await middleware.InvokeAsync(context1);
- await middleware.InvokeAsync(CreateHttpContext(path: "/api/users"));
-
- var context2 = CreateHttpContext(path: "/api/providers");
- await middleware.InvokeAsync(context2);
- await middleware.InvokeAsync(CreateHttpContext(path: "/api/providers"));
-
- // Assert - Both paths should be at their limit
- context1.Response.StatusCode.Should().Be(200);
- context2.Response.StatusCode.Should().Be(200);
-
- // Third request to each path should fail
- var context3 = CreateHttpContext(path: "/api/users");
- await middleware.InvokeAsync(context3);
- context3.Response.StatusCode.Should().Be(429);
-
- var context4 = CreateHttpContext(path: "/api/providers");
- await middleware.InvokeAsync(context4);
- context4.Response.StatusCode.Should().Be(429);
- }
-
- [Fact]
- public async Task InvokeAsync_LimitExceeded_ShouldIncludeRetryAfterHeader()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 1;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
- var context = CreateHttpContext();
-
- // Act
- await middleware.InvokeAsync(context);
- context = CreateHttpContext();
- await middleware.InvokeAsync(context);
-
- // Assert
- context.Response.StatusCode.Should().Be(429);
- context.Response.Headers.Should().ContainKey("Retry-After");
-
- var retryAfter = context.Response.Headers.RetryAfter.ToString();
- int.TryParse(retryAfter, out var seconds).Should().BeTrue();
- seconds.Should().BeGreaterThan(0).And.BeLessThanOrEqualTo(60);
- }
-
- [Fact]
- public async Task InvokeAsync_EndpointLimit_OnlyAnonymous_ShouldNotApplyToAuthenticated()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Authenticated.RequestsPerMinute = 100;
- options.EndpointLimits = new Dictionary
- {
- ["api_search"] = new EndpointLimits
- {
- Pattern = "/api/search*",
- RequestsPerMinute = 2,
- RequestsPerHour = 100,
- ApplyToAnonymous = true,
- ApplyToAuthenticated = false
- }
- };
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
-
- // Act - Authenticated user should use default auth limit, not endpoint limit
- for (int i = 0; i < 10; i++)
- {
- var context = CreateHttpContext(path: "/api/search", isAuthenticated: true);
- await middleware.InvokeAsync(context);
- }
-
- // Assert - Should succeed (using 100/min limit, not 2/min endpoint limit)
- _nextCalled.Should().BeTrue();
- }
-
- [Fact]
- public async Task InvokeAsync_PatternCacheSizeLimit_ShouldCompileOnDemandWhenLimitReached()
- {
- // Arrange
- var options = CreateDefaultOptions();
- options.General.WindowInSeconds = 60;
- options.Anonymous.RequestsPerMinute = 10;
-
- // Create 1001 unique endpoint patterns to exceed MaxPatternCacheSize (1000)
- var endpointLimits = new Dictionary();
- for (int i = 0; i < 1001; i++)
- {
- endpointLimits[$"pattern_{i}"] = new EndpointLimits
- {
- Pattern = $"/api/test{i}/*",
- RequestsPerMinute = 10,
- RequestsPerHour = 100,
- ApplyToAnonymous = true,
- ApplyToAuthenticated = true
- };
- }
- options.EndpointLimits = endpointLimits;
- _optionsMock.Setup(x => x.CurrentValue).Returns(options);
-
- var middleware = CreateMiddleware();
-
- // Act - Request all patterns to fill cache and trigger limit
- // First 1000 patterns should be cached, pattern 1001 should trigger warning
- for (int i = 0; i < 1001; i++)
- {
- var context = CreateHttpContext(path: $"/api/test{i}/data");
- await middleware.InvokeAsync(context);
- }
-
- // Verify warning was logged when cache limit reached (updated message)
- _loggerMock.Verify(
- x => x.Log(
- LogLevel.Warning,
- It.IsAny(),
- It.Is((v, t) => v.ToString()!.Contains("cache size limit reached")),
- null,
- It.IsAny>()),
- Times.AtLeastOnce);
- }
-
- // Helper methods
-
- private RateLimitingMiddleware CreateMiddleware()
- {
- return new RateLimitingMiddleware(
- next: (context) =>
- {
- _nextCalled = true;
- return Task.CompletedTask;
- },
- cache: _cache,
- options: _optionsMock.Object,
- logger: _loggerMock.Object
- );
- }
-
- private static HttpContext CreateHttpContext(
- string path = "/api/test",
- string remoteIp = "192.168.1.1",
- bool isAuthenticated = false,
- string[]? roles = null)
- {
- var context = new DefaultHttpContext();
- context.Request.Path = path;
- context.Request.Method = "GET";
- context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp);
- context.Response.Body = new MemoryStream();
-
- if (isAuthenticated)
- {
- var claims = new List
- {
- new("sub", "user123"),
- new(ClaimTypes.Name, "testuser")
- };
-
- if (roles != null)
- {
- claims.AddRange(roles.Select(r => new Claim("role", r)));
- }
-
- context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
- }
-
- return context;
- }
-
- private static RateLimitOptions CreateDefaultOptions()
- {
- return new RateLimitOptions
- {
- General = new GeneralSettings
- {
- Enabled = true,
- EnableIpWhitelist = false,
- WhitelistedIps = [],
- WindowInSeconds = 60,
- ErrorMessage = "Too many requests. Please try again later."
- },
- Anonymous = new AnonymousLimits
- {
- RequestsPerMinute = 60,
- RequestsPerHour = 1000,
- RequestsPerDay = 5000
- },
- Authenticated = new AuthenticatedLimits
- {
- RequestsPerMinute = 120,
- RequestsPerHour = 2000,
- RequestsPerDay = 10000
- },
- RoleLimits = new Dictionary(),
- EndpointLimits = new Dictionary()
- };
- }
-
- private static async Task ReadResponseBody(HttpContext context)
- {
- context.Response.Body.Seek(0, SeekOrigin.Begin);
- using var reader = new StreamReader(context.Response.Body);
- return await reader.ReadToEndAsync();
- }
-}
diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Options/OptionsTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Options/OptionsTests.cs
index e773f8611..81077d102 100644
--- a/tests/MeAjudaAi.ApiService.Tests/Unit/Options/OptionsTests.cs
+++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Options/OptionsTests.cs
@@ -1,99 +1,18 @@
using FluentAssertions;
using MeAjudaAi.ApiService.Extensions;
using MeAjudaAi.ApiService.Options;
-using MeAjudaAi.ApiService.Options.RateLimit;
namespace MeAjudaAi.ApiService.Tests.Unit.Options;
-///
-/// Testes para classes de Options (POCOs de configuração).
-///
-/// NOTA: Estes testes validam a estrutura das classes de Options, garantindo que:
-/// 1. As propriedades estão definidas corretamente
-/// 2. Os valores padrão são inicializados
-/// 3. Getters e Setters funcionam adequadamente
-///
-/// Embora essas validações sejam testadas indiretamente através dos testes de integração
-/// que usam essas Options, mantemos estes testes unitários por:
-/// - Documentação clara da estrutura esperada de cada Option
-/// - Detecção rápida de mudanças acidentais em propriedades
-/// - Validação de valores padrão sem necessidade de configuração completa
-/// - Facilita refatoração e evolução das classes de Options
-///
[Trait("Category", "Unit")]
[Trait("Layer", "ApiService")]
public class OptionsTests
{
- [Fact]
- public void CorsOptions_ShouldHaveCorrectProperties()
- {
- // Arrange & Act
- var options = new CorsOptions();
-
- // Assert
- options.Should().NotBeNull();
- options.GetType().Should().Be(typeof(CorsOptions));
- options.GetType().GetProperty("AllowedOrigins").Should().NotBeNull();
- options.GetType().GetProperty("AllowedMethods").Should().NotBeNull();
- options.GetType().GetProperty("AllowedHeaders").Should().NotBeNull();
- options.GetType().GetProperty("AllowCredentials").Should().NotBeNull();
- options.GetType().GetProperty("PreflightMaxAge").Should().NotBeNull();
- CorsOptions.SectionName.Should().Be("Cors");
- }
-
- [Fact]
- public void RateLimitOptions_ShouldHaveCorrectProperties()
- {
- // Arrange & Act
- var options = new RateLimitOptions();
-
- // Assert
- options.Should().NotBeNull();
- options.Should().BeOfType();
- }
-
- [Fact]
- public void GeneralSettings_ShouldHaveCorrectProperties()
- {
- // Arrange & Act
- var settings = new GeneralSettings();
-
- // Assert
- settings.Should().NotBeNull();
- settings.Should().BeOfType();
- }
-
- [Fact]
- public void AuthenticatedLimits_ShouldHaveCorrectProperties()
- {
- // Arrange & Act
- var limits = new AuthenticatedLimits();
-
- // Assert
- limits.Should().NotBeNull();
- limits.Should().BeOfType();
- }
-
- [Fact]
- public void AnonymousLimits_ShouldHaveCorrectProperties()
- {
- // Arrange & Act
- var limits = new AnonymousLimits();
-
- // Assert
- limits.Should().NotBeNull();
- limits.Should().BeOfType();
- }
-
- #region SecurityOptions Tests
-
[Fact]
public void SecurityOptions_DefaultValues_ShouldBeInitialized()
{
- // Arrange & Act
var options = new SecurityOptions();
- // Assert
options.Should().NotBeNull();
options.EnforceHttps.Should().BeFalse();
options.EnableStrictTransportSecurity.Should().BeFalse();
@@ -104,407 +23,33 @@ public void SecurityOptions_DefaultValues_ShouldBeInitialized()
[Fact]
public void SecurityOptions_EnforceHttps_CanBeSetAndRetrieved()
{
- // Arrange
var options = new SecurityOptions();
- // Act
options.EnforceHttps = true;
- // Assert
options.EnforceHttps.Should().BeTrue();
}
[Fact]
public void SecurityOptions_EnableStrictTransportSecurity_CanBeSetAndRetrieved()
{
- // Arrange
var options = new SecurityOptions();
- // Act
options.EnableStrictTransportSecurity = true;
- // Assert
options.EnableStrictTransportSecurity.Should().BeTrue();
}
[Fact]
public void SecurityOptions_AllowedHosts_CanBeSetAndRetrieved()
{
- // Arrange
var options = new SecurityOptions();
var expectedHosts = new List { "localhost", "example.com", "*.meajudaai.com" };
- // Act
options.AllowedHosts = expectedHosts;
- // Assert
options.AllowedHosts.Should().NotBeNull();
options.AllowedHosts.Should().HaveCount(3);
options.AllowedHosts.Should().ContainInOrder(expectedHosts);
}
-
- #endregion
-
- #region EndpointLimits Tests
-
- [Fact]
- public void EndpointLimits_DefaultValues_ShouldBeInitialized()
- {
- // Arrange & Act
- var limits = new EndpointLimits();
-
- // Assert
- limits.Should().NotBeNull();
- limits.Pattern.Should().Be(string.Empty);
- limits.RequestsPerMinute.Should().Be(60);
- limits.RequestsPerHour.Should().Be(1000);
- limits.ApplyToAuthenticated.Should().BeTrue();
- limits.ApplyToAnonymous.Should().BeTrue();
- }
-
- [Fact]
- public void EndpointLimits_Pattern_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new EndpointLimits();
- const string expectedPattern = "/api/v1/users/*";
-
- // Act
- limits.Pattern = expectedPattern;
-
- // Assert
- limits.Pattern.Should().Be(expectedPattern);
- }
-
- [Fact]
- public void EndpointLimits_RequestsPerMinute_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new EndpointLimits();
-
- // Act
- limits.RequestsPerMinute = 120;
-
- // Assert
- limits.RequestsPerMinute.Should().Be(120);
- }
-
- [Fact]
- public void EndpointLimits_RequestsPerHour_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new EndpointLimits();
-
- // Act
- limits.RequestsPerHour = 2000;
-
- // Assert
- limits.RequestsPerHour.Should().Be(2000);
- }
-
- [Fact]
- public void EndpointLimits_ApplyToAuthenticated_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new EndpointLimits();
-
- // Act
- limits.ApplyToAuthenticated = false;
-
- // Assert
- limits.ApplyToAuthenticated.Should().BeFalse();
- }
-
- [Fact]
- public void EndpointLimits_ApplyToAnonymous_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new EndpointLimits();
-
- // Act
- limits.ApplyToAnonymous = false;
-
- // Assert
- limits.ApplyToAnonymous.Should().BeFalse();
- }
-
- #endregion
-
- #region RoleLimits Tests
-
- [Fact]
- public void RoleLimits_DefaultValues_ShouldBeInitialized()
- {
- // Arrange & Act
- var limits = new RoleLimits();
-
- // Assert
- limits.Should().NotBeNull();
- limits.RequestsPerMinute.Should().Be(200);
- limits.RequestsPerHour.Should().Be(5000);
- limits.RequestsPerDay.Should().Be(20000);
- }
-
- [Fact]
- public void RoleLimits_RequestsPerMinute_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new RoleLimits();
-
- // Act
- limits.RequestsPerMinute = 300;
-
- // Assert
- limits.RequestsPerMinute.Should().Be(300);
- }
-
- [Fact]
- public void RoleLimits_RequestsPerHour_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new RoleLimits();
-
- // Act
- limits.RequestsPerHour = 10000;
-
- // Assert
- limits.RequestsPerHour.Should().Be(10000);
- }
-
- [Fact]
- public void RoleLimits_RequestsPerDay_CanBeSetAndRetrieved()
- {
- // Arrange
- var limits = new RoleLimits();
-
- // Act
- limits.RequestsPerDay = 50000;
-
- // Assert
- limits.RequestsPerDay.Should().Be(50000);
- }
-
- #endregion
-
- #region GeographicRestrictionOptions Tests
-
- [Fact]
- public void GeographicRestrictionOptions_DefaultValues_ShouldBeInitialized()
- {
- // Arrange & Act
- var options = new GeographicRestrictionOptions();
-
- // Assert
- options.Should().NotBeNull();
- options.AllowedStates.Should().NotBeNull();
- options.AllowedStates.Should().BeEmpty();
- options.AllowedCities.Should().NotBeNull();
- options.AllowedCities.Should().BeEmpty();
- options.BlockedMessage.Should().Be("Serviço indisponível na sua região. Disponível apenas em: {allowedRegions}");
- }
-
- [Fact]
- public void GeographicRestrictionOptions_AllowedStates_CanBeSetAndRetrieved()
- {
- // Arrange
- var options = new GeographicRestrictionOptions();
- var expectedStates = new List { "SP", "RJ", "MG" };
-
- // Act
- options.AllowedStates = expectedStates;
-
- // Assert
- options.AllowedStates.Should().HaveCount(3);
- options.AllowedStates.Should().ContainInOrder(expectedStates);
- }
-
- [Fact]
- public void GeographicRestrictionOptions_AllowedCities_CanBeSetAndRetrieved()
- {
- // Arrange
- var options = new GeographicRestrictionOptions();
- var expectedCities = new List { "São Paulo", "Rio de Janeiro" };
-
- // Act
- options.AllowedCities = expectedCities;
-
- // Assert
- options.AllowedCities.Should().HaveCount(2);
- options.AllowedCities.Should().ContainInOrder(expectedCities);
- }
-
- [Fact]
- public void GeographicRestrictionOptions_BlockedMessage_CanBeSetAndRetrieved()
- {
- // Arrange
- var options = new GeographicRestrictionOptions();
- const string customMessage = "Acesso negado para sua região";
-
- // Act
- options.BlockedMessage = customMessage;
-
- // Assert
- options.BlockedMessage.Should().Be(customMessage);
- }
-
- #endregion
-
- #region CorsOptions Validation Tests
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenNoAllowedOrigins()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = [],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"]
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*allowed origin*configured for CORS*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenNoAllowedMethods()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["http://localhost"],
- AllowedMethods = [],
- AllowedHeaders = ["Content-Type"]
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*allowed method*configured for CORS*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenNoAllowedHeaders()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["http://localhost"],
- AllowedMethods = ["GET"],
- AllowedHeaders = []
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*allowed header*configured for CORS*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenPreflightMaxAgeIsNegative()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["http://localhost"],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"],
- PreflightMaxAge = -1
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*PreflightMaxAge*non-negative*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenOriginIsEmpty()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["http://localhost", ""],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"]
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*allowed origins*empty values*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenOriginHasInvalidFormat()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["not-a-valid-url"],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"]
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*Invalid*origin format*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldThrowWhenWildcardUsedWithCredentials()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["*"],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"],
- AllowCredentials = true
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().Throw()
- .WithMessage("*wildcard*credentials*");
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldSucceedWithValidConfiguration()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["http://localhost:3000", "https://example.com"],
- AllowedMethods = ["GET", "POST"],
- AllowedHeaders = ["Content-Type", "Authorization"],
- PreflightMaxAge = 7200
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().NotThrow();
- }
-
- [Fact]
- public void CorsOptions_Validate_ShouldAllowWildcardOriginWithoutCredentials()
- {
- // Arrange
- var options = new CorsOptions
- {
- AllowedOrigins = ["*"],
- AllowedMethods = ["GET"],
- AllowedHeaders = ["Content-Type"],
- AllowCredentials = false
- };
-
- // Act & Assert
- var act = () => options.Validate();
- act.Should().NotThrow();
- }
-
- #endregion
-}
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Options/SecurityOptionsTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Options/SecurityOptionsTests.cs
new file mode 100644
index 000000000..cfb846c10
--- /dev/null
+++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Options/SecurityOptionsTests.cs
@@ -0,0 +1,54 @@
+using FluentAssertions;
+using MeAjudaAi.ApiService.Options;
+
+namespace MeAjudaAi.ApiService.Tests.Unit.Options;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "ApiService")]
+public class SecurityOptionsTests
+{
+ [Fact]
+ public void SecurityOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new SecurityOptions();
+
+ options.Should().NotBeNull();
+ options.EnforceHttps.Should().BeFalse();
+ options.EnableStrictTransportSecurity.Should().BeFalse();
+ options.AllowedHosts.Should().NotBeNull();
+ options.AllowedHosts.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SecurityOptions_EnforceHttps_CanBeSetAndRetrieved()
+ {
+ var options = new SecurityOptions();
+
+ options.EnforceHttps = true;
+
+ options.EnforceHttps.Should().BeTrue();
+ }
+
+ [Fact]
+ public void SecurityOptions_EnableStrictTransportSecurity_CanBeSetAndRetrieved()
+ {
+ var options = new SecurityOptions();
+
+ options.EnableStrictTransportSecurity = true;
+
+ options.EnableStrictTransportSecurity.Should().BeTrue();
+ }
+
+ [Fact]
+ public void SecurityOptions_AllowedHosts_CanBeSetAndRetrieved()
+ {
+ var options = new SecurityOptions();
+ var expectedHosts = new List { "localhost", "example.com", "*.meajudaai.com" };
+
+ options.AllowedHosts = expectedHosts;
+
+ options.AllowedHosts.Should().NotBeNull();
+ options.AllowedHosts.Should().HaveCount(3);
+ options.AllowedHosts.Should().ContainInOrder(expectedHosts);
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.ApiService.Tests/packages.lock.json b/tests/MeAjudaAi.ApiService.Tests/packages.lock.json
index ab4e0202e..ca46223c5 100644
--- a/tests/MeAjudaAi.ApiService.Tests/packages.lock.json
+++ b/tests/MeAjudaAi.ApiService.Tests/packages.lock.json
@@ -928,6 +928,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/tests/MeAjudaAi.Architecture.Tests/packages.lock.json b/tests/MeAjudaAi.Architecture.Tests/packages.lock.json
index 6ca5ceef0..d70471d60 100644
--- a/tests/MeAjudaAi.Architecture.Tests/packages.lock.json
+++ b/tests/MeAjudaAi.Architecture.Tests/packages.lock.json
@@ -828,6 +828,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/tests/MeAjudaAi.E2E.Tests/packages.lock.json b/tests/MeAjudaAi.E2E.Tests/packages.lock.json
index e3ee61194..a40367c4d 100644
--- a/tests/MeAjudaAi.E2E.Tests/packages.lock.json
+++ b/tests/MeAjudaAi.E2E.Tests/packages.lock.json
@@ -1672,6 +1672,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/tests/MeAjudaAi.Gateway.Tests/MeAjudaAi.Gateway.Tests.csproj b/tests/MeAjudaAi.Gateway.Tests/MeAjudaAi.Gateway.Tests.csproj
new file mode 100644
index 000000000..df84b17b0
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/MeAjudaAi.Gateway.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/EdgeAuthGuardMiddlewareTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/EdgeAuthGuardMiddlewareTests.cs
new file mode 100644
index 000000000..1e2560cd3
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/EdgeAuthGuardMiddlewareTests.cs
@@ -0,0 +1,197 @@
+using FluentAssertions;
+using MeAjudaAi.Gateway.Middlewares;
+using MeAjudaAi.Gateway.Options;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Middleware;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class EdgeAuthGuardMiddlewareTests
+{
+ private readonly Mock> _loggerMock;
+ private readonly EdgeAuthGuardOptions _options;
+
+ public EdgeAuthGuardMiddlewareTests()
+ {
+ _loggerMock = new Mock>();
+ _options = new EdgeAuthGuardOptions
+ {
+ Enabled = true,
+ PublicRoutes = ["/health", "/swagger", "/api/v1/auth/login"]
+ };
+ }
+
+ private EdgeAuthGuardMiddleware CreateMiddleware()
+ {
+ return new EdgeAuthGuardMiddleware(
+ _ => Task.CompletedTask,
+ Microsoft.Extensions.Options.Options.Create(_options),
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_WhenDisabled_ShouldCallNext()
+ {
+ var options = new EdgeAuthGuardOptions { Enabled = false };
+ var nextCalled = false;
+ var middleware = new EdgeAuthGuardMiddleware(
+ _ => { nextCalled = true; return Task.CompletedTask; },
+ Microsoft.Extensions.Options.Options.Create(options),
+ _loggerMock.Object);
+
+ var context = new DefaultHttpContext();
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ context.Response.StatusCode.Should().Be(200);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_PublicRoute_ShouldCallNext()
+ {
+ var nextCalled = false;
+ var middleware = new EdgeAuthGuardMiddleware(
+ _ => { nextCalled = true; return Task.CompletedTask; },
+ Microsoft.Extensions.Options.Options.Create(_options),
+ _loggerMock.Object);
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/health";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ context.Response.StatusCode.Should().Be(200);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_PublicRouteWithSwagger_ShouldCallNext()
+ {
+ var nextCalled = false;
+ var middleware = new EdgeAuthGuardMiddleware(
+ _ => { nextCalled = true; return Task.CompletedTask; },
+ Microsoft.Extensions.Options.Options.Create(_options),
+ _loggerMock.Object);
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/swagger/index.html";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ context.Response.StatusCode.Should().Be(200);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonPublicRoute_Unauthenticated_ShouldReturn401()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/providers";
+
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized);
+ context.Response.Headers["X-Gateway-Challenge"].FirstOrDefault().Should().Be("true");
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonPublicRoute_Authenticated_ShouldCallNext()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/providers";
+
+ var identity = new System.Security.Claims.ClaimsIdentity("TestAuth");
+ var claims = new System.Security.Claims.ClaimsPrincipal(identity);
+ context.User = claims;
+
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(200);
+ context.Response.Headers["X-Gateway-Authenticated"].FirstOrDefault().Should().Be("true");
+ }
+
+ [Fact]
+ public async Task InvokeAsync_PublicRoute_Authenticated_ShouldCallNext()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/auth/login";
+
+ var identity = new System.Security.Claims.ClaimsIdentity("TestAuth");
+ var claims = new System.Security.Claims.ClaimsPrincipal(identity);
+ context.User = claims;
+
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(200);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_PublicApiRoute_SetsPublicRouteItemTrue()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/auth/login";
+
+ await middleware.InvokeAsync(context);
+
+ context.Items["X-Gateway-PublicRoute"].Should().Be(true);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_ProtectedApiRoute_SetsPublicRouteItemFalse()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/providers";
+
+ var identity = new System.Security.Claims.ClaimsIdentity("TestAuth");
+ context.User = new System.Security.Claims.ClaimsPrincipal(identity);
+
+ await middleware.InvokeAsync(context);
+
+ context.Items["X-Gateway-PublicRoute"].Should().Be(false);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonApiRoute_BypassesMiddleware()
+ {
+ var nextCalled = false;
+ var middleware = new EdgeAuthGuardMiddleware(
+ _ => { nextCalled = true; return Task.CompletedTask; },
+ Microsoft.Extensions.Options.Options.Create(_options),
+ _loggerMock.Object);
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/swagger/index.html";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ context.Items.ContainsKey("X-Gateway-PublicRoute").Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_ProtectedApiRoute_Unauthenticated_Returns401WithChallengeHeader()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/api/v1/providers";
+
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(401);
+ context.Response.Headers["X-Gateway-Challenge"].FirstOrDefault().Should().Be("true");
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/GeographicRestrictionMiddlewareTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/GeographicRestrictionMiddlewareTests.cs
new file mode 100644
index 000000000..54658652b
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/GeographicRestrictionMiddlewareTests.cs
@@ -0,0 +1,388 @@
+using System.Text.Json;
+using FluentAssertions;
+using MeAjudaAi.Shared.Middleware;
+using MeAjudaAi.Shared.Utilities.Constants;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.FeatureManagement;
+using Moq;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Middleware;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GeographicRestrictionOptionsTests
+{
+ [Fact]
+ public void GeographicRestrictionOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new GeographicRestrictionOptions();
+
+ options.Should().NotBeNull();
+ options.Enabled.Should().BeFalse();
+ options.FailOpen.Should().BeTrue();
+ options.AllowedStates.Should().BeEmpty();
+ options.AllowedCities.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void GeographicRestrictionOptions_SectionName_ShouldBeGeographicRestriction()
+ {
+ GeographicRestrictionOptions.SectionName.Should().Be("GeographicRestriction");
+ }
+
+ [Fact]
+ public void GeographicRestrictionOptions_WithAllowedStates_ShouldConfigureCorrectly()
+ {
+ var options = new GeographicRestrictionOptions
+ {
+ Enabled = true,
+ AllowedStates = ["SP", "RJ", "MG"],
+ AllowedCities = ["São Paulo", "Rio de Janeiro"],
+ FailOpen = false
+ };
+
+ options.Enabled.Should().BeTrue();
+ options.AllowedStates.Should().HaveCount(3);
+ options.AllowedCities.Should().HaveCount(2);
+ options.FailOpen.Should().BeFalse();
+ }
+}
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GeographicRestrictionErrorResponseTests
+{
+ [Fact]
+ public void GeographicRestrictionErrorResponse_ShouldCreateCorrectly()
+ {
+ var response = new GeographicRestrictionErrorResponse(
+ "Access denied",
+ UserLocation.Create("São Paulo", "SP"),
+ [AllowedCity.Create("São Paulo", "SP")],
+ ["SP", "RJ"]
+ );
+
+ response.message.Should().Be("Access denied");
+ response.userLocation.Should().NotBeNull();
+ response.userLocation.City.Should().Be("São Paulo");
+ response.userLocation.State.Should().Be("SP");
+ response.allowedCities.Should().HaveCount(1);
+ response.allowedStates.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void AllowedCity_Create_ShouldSetPropertiesCorrectly()
+ {
+ var allowedCity = AllowedCity.Create("São Paulo", "SP");
+
+ allowedCity.Name.Should().Be("São Paulo");
+ allowedCity.State.Should().Be("SP");
+ }
+
+ [Fact]
+ public void UserLocation_Create_ShouldSetPropertiesCorrectly()
+ {
+ var userLocation = UserLocation.Create("São Paulo", "SP");
+
+ userLocation.City.Should().Be("São Paulo");
+ userLocation.State.Should().Be("SP");
+ }
+
+ [Fact]
+ public void UserLocation_Create_WithNullValues_ShouldWork()
+ {
+ var userLocation = UserLocation.Create(null, null);
+
+ userLocation.City.Should().BeNull();
+ userLocation.State.Should().BeNull();
+ }
+}
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GeographicRestrictionMiddlewareBehaviorTests
+{
+ private readonly Mock> _loggerMock;
+ private readonly Mock> _optionsMock;
+ private readonly Mock _featureManagerMock;
+ private readonly GeographicRestrictionOptions _options;
+
+ public GeographicRestrictionMiddlewareBehaviorTests()
+ {
+ _loggerMock = new Mock>();
+ _optionsMock = new Mock>();
+ _featureManagerMock = new Mock();
+ _options = new GeographicRestrictionOptions
+ {
+ Enabled = true,
+ FailOpen = true,
+ AllowedStates = ["SP", "RJ"],
+ AllowedCities = []
+ };
+ _optionsMock.Setup(x => x.CurrentValue).Returns(_options);
+ }
+
+ private GeographicRestrictionMiddleware CreateMiddleware(
+ RequestDelegate? next = null,
+ Action? configure = null)
+ {
+ var options = new GeographicRestrictionOptions
+ {
+ Enabled = true,
+ FailOpen = true,
+ AllowedStates = ["SP", "RJ"],
+ AllowedCities = []
+ };
+ configure?.Invoke(options);
+
+ _optionsMock.Setup(x => x.CurrentValue).Returns(options);
+
+ next ??= _ => Task.CompletedTask;
+ return new GeographicRestrictionMiddleware(
+ next,
+ _loggerMock.Object,
+ _optionsMock.Object,
+ _featureManagerMock.Object);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_FeatureFlagDisabled_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(false);
+
+ var context = new DefaultHttpContext();
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_BlockedState_Returns451()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "Salvador|BA";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AllowedState_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "São Paulo|SP";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_FailOpenTrue_NoHeader_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_HealthPath_Bypasses()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Path = "/health";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_MalformedHeader_IsAlwaysBlocked()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts => opts.FailOpen = true);
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "|";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_MalformedHeader_FailOpenFalse_Returns451()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts => opts.FailOpen = false);
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "|";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_FailOpenFalse_NoHeader_Returns451()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts => opts.FailOpen = false);
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_BlockedCity_Returns451()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts =>
+ {
+ opts.AllowedCities = ["Muriaé"];
+ opts.AllowedStates = [];
+ });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "São Paulo|SP";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_BlockedCityWithAllowedStates_Returns451()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts =>
+ {
+ opts.AllowedCities = [];
+ opts.AllowedStates = ["MG", "RJ"];
+ });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "São Paulo|SP";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_BlockedCityWithPipeFormat_Returns451WithProperResponse()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts =>
+ {
+ opts.AllowedCities = ["Muriaé"];
+ opts.AllowedStates = ["MG"];
+ opts.DefaultBlockedMessage = "Default message: {allowedRegions}";
+ });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "São Paulo|SP";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeFalse();
+ context.Response.StatusCode.Should().Be(451);
+ context.Response.ContentType.Should().Contain("application/json");
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AllowedCity_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, configure: opts =>
+ {
+ opts.Enabled = true;
+ opts.FailOpen = false;
+ opts.AllowedCities = ["Muriaé|MG"];
+ opts.AllowedStates = [];
+ });
+
+ _featureManagerMock
+ .Setup(x => x.IsEnabledAsync(FeatureFlags.GeographicRestriction))
+ .ReturnsAsync(true);
+
+ var context = new DefaultHttpContext();
+ context.Request.Headers["X-User-Location"] = "Muriaé|MG";
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/RateLimitingMiddlewareTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/RateLimitingMiddlewareTests.cs
new file mode 100644
index 000000000..0b9c82963
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/RateLimitingMiddlewareTests.cs
@@ -0,0 +1,365 @@
+using System.Text.Json;
+using FluentAssertions;
+using MeAjudaAi.Shared.Middleware;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Middleware;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class RateLimitingMiddlewareTests
+{
+ private readonly Mock> _loggerMock;
+ private readonly Mock> _optionsMock;
+ private readonly IMemoryCache _cache;
+ private readonly RateLimitingOptions _options;
+
+ public RateLimitingMiddlewareTests()
+ {
+ _loggerMock = new Mock>();
+ _optionsMock = new Mock>();
+ _cache = new MemoryCache(new MemoryCacheOptions());
+ _options = new RateLimitingOptions
+ {
+ General = new GeneralSettings
+ {
+ Enabled = true,
+ WindowInSeconds = 60,
+ EnableIpWhitelist = false,
+ WhitelistedIps = [],
+ ErrorMessage = "Rate limit exceeded"
+ },
+ Anonymous = new AnonymousLimits
+ {
+ RequestsPerMinute = 30,
+ RequestsPerHour = 300,
+ RequestsPerDay = 1000
+ },
+ Authenticated = new AuthenticatedLimits
+ {
+ RequestsPerMinute = 120,
+ RequestsPerHour = 2000,
+ RequestsPerDay = 10000
+ }
+ };
+ _optionsMock.Setup(x => x.CurrentValue).Returns(_options);
+ }
+
+ [Fact]
+ public void RateLimitingOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new RateLimitingOptions();
+
+ options.Should().NotBeNull();
+ options.General.Enabled.Should().BeTrue();
+ options.General.WindowInSeconds.Should().Be(60);
+ options.General.EnableIpWhitelist.Should().BeFalse();
+ options.General.WhitelistedIps.Should().BeEmpty();
+ options.Anonymous.RequestsPerMinute.Should().Be(30);
+ options.Authenticated.RequestsPerMinute.Should().Be(120);
+ }
+
+ [Fact]
+ public void RateLimitingOptions_SectionName_ShouldBeRateLimiting()
+ {
+ RateLimitingOptions.SectionName.Should().Be("RateLimiting");
+ }
+
+ [Fact]
+ public void GeneralSettings_DefaultValues_ShouldBeInitialized()
+ {
+ var settings = new GeneralSettings();
+
+ settings.Enabled.Should().BeTrue();
+ settings.WindowInSeconds.Should().Be(60);
+ settings.EnableIpWhitelist.Should().BeFalse();
+ settings.WhitelistedIps.Should().BeEmpty();
+ settings.ErrorMessage.Should().Be("Limite de requisições excedido. Tente novamente mais tarde.");
+ }
+
+ [Fact]
+ public void AnonymousLimits_DefaultValues_ShouldBeInitialized()
+ {
+ var limits = new AnonymousLimits();
+
+ limits.RequestsPerMinute.Should().Be(30);
+ limits.RequestsPerHour.Should().Be(300);
+ limits.RequestsPerDay.Should().Be(1000);
+ }
+
+ [Fact]
+ public void AuthenticatedLimits_DefaultValues_ShouldBeInitialized()
+ {
+ var limits = new AuthenticatedLimits();
+
+ limits.RequestsPerMinute.Should().Be(120);
+ limits.RequestsPerHour.Should().Be(2000);
+ limits.RequestsPerDay.Should().Be(10000);
+ }
+
+ [Fact]
+ public void RateLimitCounter_IncrementAndGet_ShouldReturnSequentialValues()
+ {
+ var counter = new RateLimitCounter();
+
+ counter.IncrementAndGet().Should().Be(1);
+ counter.IncrementAndGet().Should().Be(2);
+ counter.IncrementAndGet().Should().Be(3);
+ counter.Value.Should().Be(3);
+ }
+}
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class RateLimitingMiddlewareBehaviorTests
+{
+ private Mock> _loggerMock = null!;
+ private Mock> _optionsMock = null!;
+ private IMemoryCache _cache = null!;
+
+ private RateLimitingMiddleware CreateMiddleware(
+ RequestDelegate? next = null,
+ Action? configureOptions = null)
+ {
+ _loggerMock = new Mock>();
+ _optionsMock = new Mock>();
+ _cache = new MemoryCache(new MemoryCacheOptions());
+
+ var options = new RateLimitingOptions
+ {
+ General = new GeneralSettings
+ {
+ Enabled = true,
+ WindowInSeconds = 60,
+ EnableIpWhitelist = false,
+ WhitelistedIps = [],
+ ErrorMessage = "Rate limit exceeded"
+ },
+ Anonymous = new AnonymousLimits
+ {
+ RequestsPerMinute = 30,
+ RequestsPerHour = 300,
+ RequestsPerDay = 1000
+ },
+ Authenticated = new AuthenticatedLimits
+ {
+ RequestsPerMinute = 120,
+ RequestsPerHour = 2000,
+ RequestsPerDay = 10000
+ }
+ };
+ configureOptions?.Invoke(options);
+ _optionsMock.Setup(x => x.CurrentValue).Returns(options);
+
+ next ??= _ => Task.CompletedTask;
+ return new RateLimitingMiddleware(
+ next,
+ _loggerMock.Object,
+ _optionsMock.Object,
+ _cache);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_Disabled_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, opts =>
+ {
+ opts.General.Enabled = false;
+ });
+
+ var context = new DefaultHttpContext();
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_WhitelistedIp_Bypasses()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, opts =>
+ {
+ opts.General.EnableIpWhitelist = true;
+ opts.General.WhitelistedIps = ["127.0.0.1"];
+ });
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1");
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_LimitExceeded_Returns429WithRetryAfter()
+ {
+ var middleware = CreateMiddleware();
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.1.1");
+
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(429);
+ context.Response.Headers["Retry-After"].Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_UnderLimit_CallsNext()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.1.1");
+
+ await middleware.InvokeAsync(context);
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AuthenticatedUser_UsesHigherLimit()
+ {
+ var nextCalled = false;
+ var middleware = CreateMiddleware(next: _ => { nextCalled = true; return Task.CompletedTask; }, configureOptions: opts =>
+ {
+ opts.Anonymous.RequestsPerMinute = 2;
+ opts.Authenticated.RequestsPerMinute = 100;
+ });
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.1.1");
+ var claims = new List
+ {
+ new("sub", "user123")
+ };
+ var identity = new System.Security.Claims.ClaimsIdentity(claims, "Test");
+ context.User = new System.Security.Claims.ClaimsPrincipal(identity);
+
+ for (int i = 0; i < 3; i++)
+ {
+ await middleware.InvokeAsync(context);
+ }
+
+ nextCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AnonymousUser_UsesLowerLimit()
+ {
+ var middleware = CreateMiddleware(configureOptions: opts =>
+ {
+ opts.Anonymous.RequestsPerMinute = 2;
+ opts.Authenticated.RequestsPerMinute = 100;
+ });
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.1.1");
+
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(429);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_LimitExceeded_SetsCorrectStatusCodeAndHeaders()
+ {
+ var loggerMock = new Mock>();
+ var optionsMock = new Mock>();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var options = new RateLimitingOptions
+ {
+ General = new GeneralSettings
+ {
+ Enabled = true,
+ WindowInSeconds = 60,
+ ErrorMessage = "Custom rate limit message"
+ },
+ Anonymous = new AnonymousLimits
+ {
+ RequestsPerMinute = 30,
+ RequestsPerHour = 300,
+ RequestsPerDay = 1000
+ }
+ };
+ optionsMock.Setup(x => x.CurrentValue).Returns(options);
+
+ var middleware = new RateLimitingMiddleware(
+ _ => Task.CompletedTask,
+ loggerMock.Object,
+ optionsMock.Object,
+ cache);
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
+ context.Request.Method = "GET";
+
+ for (int i = 0; i < 35; i++)
+ {
+ await middleware.InvokeAsync(context);
+ }
+
+ context.Response.StatusCode.Should().Be(429);
+ context.Response.ContentType.Should().Contain("application/json");
+ context.Response.Headers["Retry-After"].Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public async Task InvokeAsync_LimitExceeded_WritesJsonBody()
+ {
+ var loggerMock = new Mock>();
+ var optionsMock = new Mock>();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var options = new RateLimitingOptions
+ {
+ General = new GeneralSettings
+ {
+ Enabled = true,
+ WindowInSeconds = 60
+ },
+ Anonymous = new AnonymousLimits
+ {
+ RequestsPerMinute = 30
+ }
+ };
+ optionsMock.Setup(x => x.CurrentValue).Returns(options);
+
+ var middleware = new RateLimitingMiddleware(
+ _ => Task.CompletedTask,
+ loggerMock.Object,
+ optionsMock.Object,
+ cache);
+
+ var context = new DefaultHttpContext();
+ context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
+ context.Response.Body = new MemoryStream();
+
+ for (int i = 0; i < 35; i++)
+ await middleware.InvokeAsync(context);
+
+ context.Response.StatusCode.Should().Be(429);
+ context.Response.ContentType.Should().Contain("application/json");
+
+ context.Response.Body.Position = 0;
+ using var reader = new StreamReader(context.Response.Body);
+ var json = await reader.ReadToEndAsync();
+ json.Should().Contain("\"error\"");
+ json.Should().Contain("\"retryAfterSeconds\"");
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/ResilientForwarderHttpClientFactoryTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/ResilientForwarderHttpClientFactoryTests.cs
new file mode 100644
index 000000000..3bc1e1180
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Middleware/ResilientForwarderHttpClientFactoryTests.cs
@@ -0,0 +1,253 @@
+using System.Net;
+using FluentAssertions;
+using MeAjudaAi.Gateway.Middlewares;
+using MeAjudaAi.Gateway.Options;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Yarp.ReverseProxy.Forwarder;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Middleware;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class ResilientForwarderHttpClientFactoryTests
+{
+ private readonly Mock> _loggerMock;
+
+ public ResilientForwarderHttpClientFactoryTests()
+ {
+ _loggerMock = new Mock>();
+ }
+
+ private ResilientForwarderHttpClientFactory CreateFactory(GatewayResilienceOptions options)
+ {
+ return new ResilientForwarderHttpClientFactory(
+ Microsoft.Extensions.Options.Options.Create(options),
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public void CreateHandler_WithRetryEnabled_ReturnsHandlerWithRetry()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 3,
+ RetryBaseDelayMs = 100,
+ RetryableMethods = ["GET", "HEAD"]
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+
+ var delegatingHandler = handler as DelegatingHandler;
+ delegatingHandler.Should().NotBeNull();
+ delegatingHandler!.InnerHandler.Should().NotBeNull();
+ delegatingHandler.InnerHandler.Should().BeOfType();
+ }
+
+ [Fact]
+ public void CreateHandler_WithRetryDisabled_ReturnsSocketsHttpHandler()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 0,
+ TimeoutSeconds = 30
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+
+ handler.Should().BeOfType();
+ }
+
+ [Fact]
+ public void CreateHandler_SocketsHttpHandler_HasCorrectTimeouts()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ TimeoutSeconds = 45,
+ RetryCount = 0
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext()) as SocketsHttpHandler;
+
+ handler.Should().NotBeNull();
+ handler!.ConnectTimeout.Should().Be(TimeSpan.FromSeconds(45));
+ handler.ResponseDrainTimeout.Should().Be(TimeSpan.FromSeconds(45));
+ }
+
+ [Fact]
+ public void CreateClient_ReturnsHttpMessageInvoker()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 0,
+ TimeoutSeconds = 30
+ };
+
+ var factory = CreateFactory(options);
+ var client = factory.CreateClient(new ForwarderHttpClientContext());
+
+ client.Should().BeOfType();
+ }
+
+ [Fact]
+ public void CreateHandler_WithRetryMethods_UsesCustomMethods()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 2,
+ RetryableMethods = ["GET", "POST", "PUT"]
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+
+ var delegatingHandler = handler as DelegatingHandler;
+ delegatingHandler.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateHandler_RetryableMethods_IncludesExpectedMethods()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 1,
+ RetryableMethods = ["GET", "HEAD", "PUT", "DELETE"]
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+
+ handler.Should().BeAssignableTo();
+
+ var delegatingHandler = (DelegatingHandler)handler;
+ delegatingHandler.InnerHandler.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateHandler_NonRetryableMethods_ExcludesPost()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ RetryCount = 1,
+ RetryableMethods = ["GET", "HEAD"]
+ };
+
+ var factory = CreateFactory(options);
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+
+ handler.Should().BeAssignableTo();
+ }
+
+ [Fact]
+ public async Task SendAsync_Get_Retries_UntilSuccess()
+ {
+ var options = new GatewayResilienceOptions { RetryCount = 2, RetryBaseDelayMs = 1 };
+ var factory = CreateFactory(options);
+
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+ var delegating = handler as DelegatingHandler;
+ delegating.Should().NotBeNull();
+
+ int calls = 0;
+ delegating!.InnerHandler = new StubHandler(() =>
+ {
+ calls++;
+ return calls < 3
+ ? new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ : new HttpResponseMessage(HttpStatusCode.OK);
+ });
+
+ using var client = new HttpMessageInvoker(delegating);
+ var resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://unit.test"), CancellationToken.None);
+
+ calls.Should().Be(3);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task SendAsync_Post_DoesNotRetry_ByDefault()
+ {
+ var options = new GatewayResilienceOptions { RetryCount = 3, RetryBaseDelayMs = 1 };
+ var factory = CreateFactory(options);
+
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+ var delegating = handler as DelegatingHandler;
+ delegating.Should().NotBeNull();
+
+ int calls = 0;
+ delegating!.InnerHandler = new StubHandler(() =>
+ {
+ calls++;
+ return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
+ });
+
+ using var client = new HttpMessageInvoker(delegating);
+ var resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "http://unit.test"), CancellationToken.None);
+
+ calls.Should().Be(1);
+ resp.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
+ }
+
+ [Fact]
+ public async Task SendAsync_TransientException_Retries_ThenSucceeds()
+ {
+ var options = new GatewayResilienceOptions { RetryCount = 2, RetryBaseDelayMs = 1 };
+ var factory = CreateFactory(options);
+
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+ var delegating = handler as DelegatingHandler;
+ delegating.Should().NotBeNull();
+
+ int calls = 0;
+ delegating!.InnerHandler = new ExceptionThenSuccessHandler(() =>
+ {
+ calls++;
+ if (calls < 3) throw new HttpRequestException("Transient");
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+
+ using var client = new HttpMessageInvoker(delegating);
+ var resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://unit.test"), CancellationToken.None);
+
+ calls.Should().Be(3);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task SendAsync_AllRetries_Exhausted_Throws()
+ {
+ var options = new GatewayResilienceOptions { RetryCount = 2, RetryBaseDelayMs = 1 };
+ var factory = CreateFactory(options);
+
+ var handler = factory.CreateHandler(new ForwarderHttpClientContext());
+ var delegating = handler as DelegatingHandler;
+ delegating.Should().NotBeNull();
+
+ delegating!.InnerHandler = new ExceptionThenSuccessHandler(() => throw new HttpRequestException("Always"));
+
+ using var client = new HttpMessageInvoker(delegating);
+ var act = async () => await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://unit.test"), CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly Func _responder;
+ public StubHandler(Func responder) => _responder = responder;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(_responder());
+ }
+
+ private sealed class ExceptionThenSuccessHandler : HttpMessageHandler
+ {
+ private readonly Func _responder;
+ public ExceptionThenSuccessHandler(Func responder) => _responder = responder;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(_responder());
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Options/EdgeAuthGuardOptionsTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/EdgeAuthGuardOptionsTests.cs
new file mode 100644
index 000000000..c52a5bfa2
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/EdgeAuthGuardOptionsTests.cs
@@ -0,0 +1,53 @@
+using FluentAssertions;
+using MeAjudaAi.Gateway.Options;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Options;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class EdgeAuthGuardOptionsTests
+{
+ [Fact]
+ public void EdgeAuthGuardOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new EdgeAuthGuardOptions();
+
+ options.Should().NotBeNull();
+ options.Enabled.Should().BeTrue();
+ options.PublicRoutes.Should().NotBeEmpty();
+ options.PublicRoutes.Should().Contain("/health");
+ options.PublicRoutes.Should().Contain("/swagger");
+ options.ChallengeHeader.Should().Be("X-Gateway-Challenge");
+ options.AuthenticatedHeader.Should().Be("X-Gateway-Authenticated");
+ }
+
+ [Fact]
+ public void EdgeAuthGuardOptions_SectionName_ShouldBeEdgeAuthGuard()
+ {
+ EdgeAuthGuardOptions.SectionName.Should().Be("EdgeAuthGuard");
+ }
+
+ [Fact]
+ public void EdgeAuthGuardOptions_WithCustomPublicRoutes_ShouldConfigureCorrectly()
+ {
+ var customRoutes = new List
+ {
+ "/api/v1/public",
+ "/api/v1/auth/login",
+ "/health"
+ };
+
+ var options = new EdgeAuthGuardOptions
+ {
+ Enabled = false,
+ PublicRoutes = customRoutes,
+ ChallengeHeader = "X-Custom-Challenge",
+ AuthenticatedHeader = "X-Custom-Auth"
+ };
+
+ options.Enabled.Should().BeFalse();
+ options.PublicRoutes.Should().HaveCount(3);
+ options.ChallengeHeader.Should().Be("X-Custom-Challenge");
+ options.AuthenticatedHeader.Should().Be("X-Custom-Auth");
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayOptionsTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayOptionsTests.cs
new file mode 100644
index 000000000..439416d23
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayOptionsTests.cs
@@ -0,0 +1,77 @@
+using FluentAssertions;
+using MeAjudaAi.Gateway.Options;
+using MeAjudaAi.Shared.Middleware;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Options;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GatewayCorsOptionsTests
+{
+ [Fact]
+ public void GatewayCorsOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new GatewayCorsOptions();
+
+ options.Should().NotBeNull();
+ options.AllowedOrigins.Should().BeEmpty();
+ options.AllowedMethods.Should().Contain("GET");
+ options.AllowedHeaders.Should().Contain("*");
+ options.AllowCredentials.Should().BeTrue();
+ options.MaxAgeSeconds.Should().Be(3600);
+ }
+
+ [Fact]
+ public void GatewayCorsOptions_SectionName_ShouldBeCors()
+ {
+ GatewayCorsOptions.SectionName.Should().Be("Cors");
+ }
+}
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class RateLimitingOptionsTests
+{
+ [Fact]
+ public void RateLimitingOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new RateLimitingOptions();
+
+ options.Should().NotBeNull();
+ options.General.Enabled.Should().BeTrue();
+ options.General.WindowInSeconds.Should().Be(60);
+ options.General.EnableIpWhitelist.Should().BeFalse();
+ options.General.WhitelistedIps.Should().BeEmpty();
+ options.Anonymous.RequestsPerMinute.Should().Be(30);
+ options.Authenticated.RequestsPerMinute.Should().Be(120);
+ }
+
+ [Fact]
+ public void RateLimitingOptions_SectionName_ShouldBeRateLimiting()
+ {
+ RateLimitingOptions.SectionName.Should().Be("RateLimiting");
+ }
+}
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GeographicRestrictionOptionsTests
+{
+ [Fact]
+ public void GeographicRestrictionOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new GeographicRestrictionOptions();
+
+ options.Should().NotBeNull();
+ options.Enabled.Should().BeFalse();
+ options.FailOpen.Should().BeTrue();
+ options.AllowedStates.Should().BeEmpty();
+ options.AllowedCities.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void GeographicRestrictionOptions_SectionName_ShouldBeGeographicRestriction()
+ {
+ GeographicRestrictionOptions.SectionName.Should().Be("GeographicRestriction");
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayResilienceOptionsTests.cs b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayResilienceOptionsTests.cs
new file mode 100644
index 000000000..2ff9cd57e
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/Unit/Options/GatewayResilienceOptionsTests.cs
@@ -0,0 +1,47 @@
+using FluentAssertions;
+using MeAjudaAi.Gateway.Options;
+
+namespace MeAjudaAi.Gateway.Tests.Unit.Options;
+
+[Trait("Category", "Unit")]
+[Trait("Layer", "Gateway")]
+public class GatewayResilienceOptionsTests
+{
+ [Fact]
+ public void GatewayResilienceOptions_DefaultValues_ShouldBeInitialized()
+ {
+ var options = new GatewayResilienceOptions();
+
+ options.Should().NotBeNull();
+ options.TimeoutSeconds.Should().Be(30);
+ options.RetryCount.Should().Be(3);
+ options.RetryBaseDelayMs.Should().Be(100);
+ options.RetryableMethods.Should().Contain("GET");
+ options.RetryableMethods.Should().Contain("HEAD");
+ options.RetryableMethods.Should().Contain("OPTIONS");
+ }
+
+ [Fact]
+ public void GatewayResilienceOptions_SectionName_ShouldBeGatewayResilience()
+ {
+ GatewayResilienceOptions.SectionName.Should().Be("GatewayResilience");
+ }
+
+ [Fact]
+ public void GatewayResilienceOptions_WithCustomValues_ShouldConfigureCorrectly()
+ {
+ var options = new GatewayResilienceOptions
+ {
+ TimeoutSeconds = 60,
+ RetryCount = 5,
+ RetryBaseDelayMs = 200,
+ RetryableMethods = ["GET", "POST", "PUT"]
+ };
+
+ options.TimeoutSeconds.Should().Be(60);
+ options.RetryCount.Should().Be(5);
+ options.RetryBaseDelayMs.Should().Be(200);
+ options.RetryableMethods.Should().HaveCount(3);
+ options.RetryableMethods.Should().Contain("POST");
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Gateway.Tests/packages.lock.json b/tests/MeAjudaAi.Gateway.Tests/packages.lock.json
new file mode 100644
index 000000000..eed94dd9b
--- /dev/null
+++ b/tests/MeAjudaAi.Gateway.Tests/packages.lock.json
@@ -0,0 +1,1695 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "coverlet.collector": {
+ "type": "Direct",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "WFejCcOUR6k8UYyDnnR6Gk+obFYMsWrZuNqPJnsVFGVhpPSN0y20D4qbdKJnXinYGx9PQ397Hf9TnU1NBST8vA=="
+ },
+ "FluentAssertions": {
+ "type": "Direct",
+ "requested": "[8.9.0, )",
+ "resolved": "8.9.0",
+ "contentHash": "Y5RDjxaVlxWX2yy0X/ay1tJjSKMOtjepSb83mmfngFS63hm3LsoZNj6nhmImzm1ifRmpF9ouvmHjx9nNwnkpDg=="
+ },
+ "Microsoft.AspNetCore.Mvc.Testing": {
+ "type": "Direct",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "lWyzApi1g8/r0eXqZBbl5bA8zewS8koxOir83/+OvJcyn2HazdUzPFd4MWc9uMdVzCRX6Z5aY4tNK+N0pWXMLg==",
+ "dependencies": {
+ "Microsoft.AspNetCore.TestHost": "10.0.7",
+ "Microsoft.Extensions.DependencyModel": "10.0.7",
+ "Microsoft.Extensions.Hosting": "10.0.7"
+ }
+ },
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[18.4.0, )",
+ "resolved": "18.4.0",
+ "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "18.4.0",
+ "Microsoft.TestPlatform.TestHost": "18.4.0"
+ }
+ },
+ "Moq": {
+ "type": "Direct",
+ "requested": "[4.20.72, )",
+ "resolved": "4.20.72",
+ "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==",
+ "dependencies": {
+ "Castle.Core": "5.1.1"
+ }
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[3.1.5, )",
+ "resolved": "3.1.5",
+ "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Asp.Versioning.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "cMRE5nvNMfBgfkb0XFWst/7UtyXCjoAXnV0L4Scx4P9fcf0idgrj1Z0c+3ylsy01K4cOib7dKhCBfpg5z3r0Kg==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.0"
+ }
+ },
+ "Azure.Core": {
+ "type": "Transitive",
+ "resolved": "1.50.0",
+ "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.ClientModel": "1.8.0",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "Azure.Monitor.OpenTelemetry.Exporter": {
+ "type": "Transitive",
+ "resolved": "1.5.0",
+ "contentHash": "7YgW82V13PwhjrlaN2Nbu9UIvYMzZxjgV9TYqK34PK+81IWsDwPO3vBhyeHYpDBwKWm7wqHp1c3VVX5DN4G2WA==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "OpenTelemetry": "1.14.0",
+ "OpenTelemetry.Extensions.Hosting": "1.14.0",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ }
+ },
+ "Castle.Core": {
+ "type": "Transitive",
+ "resolved": "5.1.1",
+ "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "6.0.0"
+ }
+ },
+ "Dapper.AOT": {
+ "type": "Transitive",
+ "resolved": "1.0.48",
+ "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
+ },
+ "Hangfire.NetCore": {
+ "type": "Transitive",
+ "resolved": "1.8.23",
+ "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==",
+ "dependencies": {
+ "Hangfire.Core": "[1.8.23]",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0",
+ "Microsoft.Extensions.Hosting.Abstractions": "3.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "3.0.0"
+ }
+ },
+ "Humanizer.Core": {
+ "type": "Transitive",
+ "resolved": "2.14.1",
+ "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
+ "Microsoft.Bcl.TimeProvider": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA=="
+ },
+ "Microsoft.CodeAnalysis.Analyzers": {
+ "type": "Transitive",
+ "resolved": "3.11.0",
+ "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
+ },
+ "Microsoft.CodeAnalysis.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "17.11.31",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "Microsoft.Extensions.DependencyInjection": "9.0.0",
+ "Microsoft.Extensions.Logging": "9.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.0",
+ "Microsoft.Extensions.Options": "9.0.0",
+ "Microsoft.Extensions.Primitives": "9.0.0",
+ "Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
+ "Newtonsoft.Json": "13.0.3",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "18.4.0",
+ "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow=="
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "TuxExnfIS/bSq3z2CbH0LwZH1oyj9iHhSGneU4fpxl3ikjZGZdSae9gcfnImV1rufH8f/ab1NnHwyL2BLyeZOg=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "eZnMyiJzo249Ejg5CaFScvJS0u7neQfS9DXknAHTO6FHVMM99gO0byNXHGZmA/BOkZ13ngeVziQLHTMOtgescg=="
+ },
+ "Microsoft.Extensions.AmbientMetadata.Application": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "lCJjEDknSYeTXB133DwLNwXYA6q9nzJiJFjQb1KO1n3sS6wHfROm6zqG6y3UthQP5oPnNbE1a7M15LpjSf5yBg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.6",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.6",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.6"
+ }
+ },
+ "Microsoft.Extensions.Caching.Memory": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "6eULH/sc97yfCEV31g7AgUzHc7dIm0DGBcofoE8GgBaXbdAPPhathN8rYcgi1TSiG1QucCdqKiVNaDEPAEXL5Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Compliance.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "xbWZji13Vb2jDJNtwVrKpI09jd8x3n3fL+GzhiLK+8O5Wc2A+GyqCZalST2fV46Pf0QfCwkXf83y+3/rDkCd7A==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.6",
+ "Microsoft.Extensions.ObjectPool": "10.0.6"
+ }
+ },
+ "Microsoft.Extensions.Configuration.CommandLine": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "3lNjglxfFxOzI9zG+3HSg/YSGqo//8Fqw6u6iuIamZb4JCorbA3JLaeWOpfKTAPi2UJwaispOXWx14dUqcGz4A==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.FileExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "qbZLvLsoTdArSloEnSxs21P781YUmwVmHc5NJPQD/ezAreQ7884z+6QfAZVKi86WAZtzx83jK2uC4itxOM44gQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.UserSecrets": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "YqVIICoIdl0016wkeO2WQS+uEbEXbUhMLKdC5rZNl1X3nu59F+nwaAHdHjq/4OK+Cx31DYmNUSFh+MUot8qSDw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Configuration.Json": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.AutoActivation": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "vby/PzPScy9pX3r3f5UuHutxSr4Q8SXqyIiH6+JEK7SVpTCL6f8R9mp04OUVsZLlsME2rBjA9PHXf9L9aG7wbg==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.6"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "l+smp1qPlU0OUXD0OGfdp7OUFrbdq7ZaP5T7m2WpfZ4RFKD7iG73BAT7tjSMxNmbSXkhAn1jYHOAqzYG1r9sNg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "+jdC9YUfMkX9/Yb3Pi8Kovt1nFVGGB2UqSHZgLapo63d+WAhYf9KiuNA3jiaaRINhVyCgWuKFoMtjWKET5oXEQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.6"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "Transitive",
+ "resolved": "10.0.5",
+ "contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Options": "10.0.5"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.5",
+ "contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Physical": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "zhgWg/i0ECj5v0jLFBSZHplvc5ygCI91DR4nne+BP4XAKF5ycz0pEKnFiTw8C1jCABJEZsnBZh6pXAvn71kFmw==",
+ "dependencies": {
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileSystemGlobbing": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "NTUspqB+vH9g4wAD6KPOBx01xqYuKXR/cHXm449zpbq1GqfjdAxBmg7eJXrNsPw7SKwIdT2cJ05GxYVvc+lvsA=="
+ },
+ "Microsoft.Extensions.Http.Diagnostics": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "HoWdJKvBt7vkLlclRbjDTXcCp3s9hwFf1CY4ovlmMKFAbKSI7zKl0fUQ4LMvUI3sHIhpEtMjp7Mxjaf/yEmVvQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Http": "10.0.6",
+ "Microsoft.Extensions.Telemetry": "10.5.0"
+ }
+ },
+ "Microsoft.Extensions.Logging.Debug": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "Y6DSt/JZApunYWKqTtqbdsR6iqAvHx3D0tavbNJ1rnC24MUpF+3XO/VKgFi+9PFqMyvQ2GHBBGb8H3cLSw7rDg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "1C8eTuxF6BLncNSJ1HCfmaBcjpUSqQDPlBVdYTlet9oldHTPpNh9iatxSJLs8TOqdp/FOpH+nSLdBve7fu9mTQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "System.Diagnostics.EventLog": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventSource": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "YWfndnDX1jVMGCN8d5T+rO+BO8sDw6BkYlUk0BYui+WP7+HhlWx8QLdA4yUDjrkGVb3AQxIWWEPVKw5Nnfj5GQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "Transitive",
+ "resolved": "10.0.6",
+ "contentHash": "2Jafd4fdxxiwiQ08mcF+Lf3vqikkQZusGVThOKZNSmPDceGk4IwkjeHL7OEb9Ov8q9ICY5wofL98CS153K5VvQ=="
+ },
+ "Microsoft.Extensions.Options.ConfigurationExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "IT7f+EMXZtkjatEcF+o6aOw/7OE4etRrMiDGEWH/iiTu2R3uhC4NEQJCfHiibtX45U3sIQ5Fh6tbb1qaOz3YAg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw=="
+ },
+ "Microsoft.Extensions.Resilience": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "yjbGQkSqLkP8/lKZLfaUcdkNUpWUqMafCsm56kw9uzznhJb/uJiIRy5/zG9D0SFsBzJkz2AcvWU2J/MJydPxoA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics": "10.0.6",
+ "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.5.0",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.6",
+ "Microsoft.Extensions.Telemetry.Abstractions": "10.5.0",
+ "Polly.Extensions": "8.4.2",
+ "Polly.RateLimiting": "8.4.2"
+ }
+ },
+ "Microsoft.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "jI7b9rkfoz06ZEQols6WG3D0iQMIbtRDHkx1F7QvQOSDmzyXLwUIBbJEO8ftr7aD/2tvsHplqycp+WXFvMfujg==",
+ "dependencies": {
+ "Microsoft.Extensions.AmbientMetadata.Application": "10.5.0",
+ "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.5.0",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.6",
+ "Microsoft.Extensions.ObjectPool": "10.0.6",
+ "Microsoft.Extensions.Telemetry.Abstractions": "10.5.0"
+ }
+ },
+ "Microsoft.Extensions.Telemetry.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.5.0",
+ "contentHash": "VmU7e6xHqoubWKl7y9MtWyQAjlDpvbds3gY8ZKMS/1GxY2+U1/aMNnMj09aOXAa3p5qhHSSkBzDJvyokCjVkPg==",
+ "dependencies": {
+ "Microsoft.Extensions.Compliance.Abstractions": "10.5.0",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.6",
+ "Microsoft.Extensions.ObjectPool": "10.0.6",
+ "Microsoft.Extensions.Options": "10.0.6"
+ }
+ },
+ "Microsoft.FeatureManagement": {
+ "type": "Transitive",
+ "resolved": "4.5.0",
+ "contentHash": "9VBxTZUwna9x31+OmOyX0DTBGEuvVrV81fAZ/XRkLESuDu5EZn/o6W8454NWOPHBIUyTGTMpYOFzI8ArkCNmCg==",
+ "dependencies": {
+ "Microsoft.Bcl.TimeProvider": "8.0.1",
+ "Microsoft.Extensions.Caching.Memory": "8.0.1",
+ "Microsoft.Extensions.Configuration": "8.0.0",
+ "Microsoft.Extensions.Configuration.Binder": "8.0.2",
+ "Microsoft.Extensions.Logging": "8.0.1"
+ }
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "6NrxQGcZg6IunkN8K2F0UVMavNpfCjbjjjON7PYcL8FwI8aULKUreiHsRX/yaA8j3XsTJnQKUYpoQk5gBjULZw=="
+ },
+ "Microsoft.IdentityModel.JsonWebTokens": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "JbFZ3OVwtvqcqgBL0cIkhRYbIP7u9GIUYLOgbNqLWtBtZY8tGDpdGyXMzUVX0gVHq1ovuHsKZrkVv+ziHEnBHw==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.17.0"
+ }
+ },
+ "Microsoft.IdentityModel.Logging": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "w1vjfri0BWqW7RkSZY3ZsqekNfIJJg5BQSFs2j+a+pCXOVrkezmJcn74pT3djwjXJh71577C6wJQgNc2UPz30w==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.17.0"
+ }
+ },
+ "Microsoft.IdentityModel.Tokens": {
+ "type": "Transitive",
+ "resolved": "8.17.0",
+ "contentHash": "teaW35URIV2x78Tzk+dVJiC4M62/9mQoSEoDjDGoEZmcQa3H2rE+XQpm9Tmdo9KK1Lcrnve4zoyLavl69kCFGg==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0",
+ "Microsoft.IdentityModel.Logging": "8.17.0"
+ }
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "18.4.0",
+ "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg=="
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "18.4.0",
+ "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "18.4.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.VisualStudio.SolutionPersistence": {
+ "type": "Transitive",
+ "resolved": "1.0.52",
+ "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "Mono.TextTemplating": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "Npgsql.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.1",
+ "contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
+ "Npgsql": "10.0.1"
+ }
+ },
+ "Npgsql.OpenTelemetry": {
+ "type": "Transitive",
+ "resolved": "10.0.1",
+ "contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
+ "dependencies": {
+ "Npgsql": "10.0.1",
+ "OpenTelemetry.API": "1.14.0"
+ }
+ },
+ "OpenTelemetry": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Api": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
+ },
+ "OpenTelemetry.Api.ProviderBuilderExtensions": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
+ "OpenTelemetry.Api": "1.15.3"
+ }
+ },
+ "OpenTelemetry.PersistentStorage.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.0.2",
+ "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ },
+ "OpenTelemetry.PersistentStorage.FileSystem": {
+ "type": "Transitive",
+ "resolved": "1.0.2",
+ "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "dependencies": {
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ }
+ },
+ "Pipelines.Sockets.Unofficial": {
+ "type": "Transitive",
+ "resolved": "2.2.8",
+ "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ=="
+ },
+ "Polly.Core": {
+ "type": "Transitive",
+ "resolved": "8.6.6",
+ "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ=="
+ },
+ "Polly.Extensions": {
+ "type": "Transitive",
+ "resolved": "8.4.2",
+ "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Options": "8.0.0",
+ "Polly.Core": "8.4.2"
+ }
+ },
+ "Polly.Extensions.Http": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==",
+ "dependencies": {
+ "Polly": "7.1.0"
+ }
+ },
+ "Polly.RateLimiting": {
+ "type": "Transitive",
+ "resolved": "8.4.2",
+ "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
+ "dependencies": {
+ "Polly.Core": "8.4.2",
+ "System.Threading.RateLimiting": "8.0.0"
+ }
+ },
+ "Serilog.Extensions.Hosting": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.0",
+ "Serilog": "4.3.0",
+ "Serilog.Extensions.Logging": "10.0.0"
+ }
+ },
+ "Serilog.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": "10.0.0",
+ "Serilog": "4.2.0"
+ }
+ },
+ "Serilog.Formatting.Compact": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.Debug": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.File": {
+ "type": "Transitive",
+ "resolved": "7.0.0",
+ "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==",
+ "dependencies": {
+ "Serilog": "4.2.0"
+ }
+ },
+ "StackExchange.Redis": {
+ "type": "Transitive",
+ "resolved": "2.7.27",
+ "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.0",
+ "Pipelines.Sockets.Unofficial": "2.2.8"
+ }
+ },
+ "System.ClientModel": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Composition": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Convention": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0",
+ "System.Composition.TypedParts": "9.0.0"
+ }
+ },
+ "System.Composition.AttributedModel": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
+ },
+ "System.Composition.Convention": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0"
+ }
+ },
+ "System.Composition.Hosting": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
+ "dependencies": {
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Composition.Runtime": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
+ },
+ "System.Composition.TypedParts": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.7",
+ "contentHash": "WbmDLeTPYhEzXhvYVioTVn/D1XX6bovyny9n5p8Zxtf03+eY385RB818teZm6n+fA63iZNvng0/Np4tLuhkMhQ=="
+ },
+ "System.Memory.Data": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg=="
+ },
+ "System.Threading.RateLimiting": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ },
+ "meajudaai.contracts": {
+ "type": "Project",
+ "dependencies": {
+ "FluentValidation": "[12.1.1, )"
+ }
+ },
+ "meajudaai.gateway": {
+ "type": "Project",
+ "dependencies": {
+ "MeAjudaAi.Modules.Locations.Infrastructure": "[1.0.0, )",
+ "MeAjudaAi.ServiceDefaults": "[1.0.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )",
+ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.Extensions.Http.Polly": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
+ "Yarp.ReverseProxy": "[2.3.0, )"
+ }
+ },
+ "meajudaai.modules.locations.application": {
+ "type": "Project",
+ "dependencies": {
+ "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.modules.locations.domain": {
+ "type": "Project",
+ "dependencies": {
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.modules.locations.infrastructure": {
+ "type": "Project",
+ "dependencies": {
+ "EFCore.NamingConventions": "[10.0.1, )",
+ "MeAjudaAi.Modules.Locations.Application": "[1.0.0, )",
+ "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )"
+ }
+ },
+ "meajudaai.servicedefaults": {
+ "type": "Project",
+ "dependencies": {
+ "Aspire.Npgsql": "[13.2.4, )",
+ "Azure.Monitor.OpenTelemetry.AspNetCore": "[1.4.0, )",
+ "MeAjudaAi.Shared": "[1.0.0, )",
+ "Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
+ "OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.14.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
+ }
+ },
+ "meajudaai.shared": {
+ "type": "Project",
+ "dependencies": {
+ "Asp.Versioning.Mvc": "[10.0.0, )",
+ "Asp.Versioning.Mvc.ApiExplorer": "[10.0.0, )",
+ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )",
+ "AspNetCore.HealthChecks.Redis": "[9.0.0, )",
+ "Dapper": "[2.1.72, )",
+ "EFCore.NamingConventions": "[10.0.1, )",
+ "FluentValidation": "[12.1.1, )",
+ "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )",
+ "Hangfire.AspNetCore": "[1.8.23, )",
+ "Hangfire.Core": "[1.8.23, )",
+ "Hangfire.PostgreSql": "[1.21.1, )",
+ "MeAjudaAi.Contracts": "[1.0.0, )",
+ "Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
+ "Microsoft.EntityFrameworkCore": "[10.0.7, )",
+ "Microsoft.EntityFrameworkCore.Design": "[10.0.7, )",
+ "Microsoft.Extensions.Caching.Hybrid": "[10.5.0, )",
+ "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
+ "Newtonsoft.Json": "[13.0.4, )",
+ "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )",
+ "RabbitMQ.Client": "[7.2.1, )",
+ "Rebus": "[8.9.2, )",
+ "Rebus.RabbitMq": "[10.1.1, )",
+ "Rebus.ServiceProvider": "[10.7.2, )",
+ "Scrutor": "[7.0.0, )",
+ "Serilog": "[4.3.1, )",
+ "Serilog.AspNetCore": "[10.0.0, )",
+ "Serilog.Enrichers.Environment": "[3.0.1, )",
+ "Serilog.Enrichers.Process": "[3.0.0, )",
+ "Serilog.Enrichers.Thread": "[4.0.0, )",
+ "Serilog.Settings.Configuration": "[10.0.0, )",
+ "Serilog.Sinks.Console": "[6.1.1, )",
+ "Serilog.Sinks.Seq": "[9.0.0, )"
+ }
+ },
+ "Asp.Versioning.Http": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "xmNm9FM2d20NKy7i1osEQysf7pJ4iJjWnM6e8CoeIhUREqG8nugsfC82pGpmzlatjAJL5T52ieSpyW+GFdSsSQ==",
+ "dependencies": {
+ "Asp.Versioning.Abstractions": "10.0.0"
+ }
+ },
+ "Asp.Versioning.Mvc": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "W0wZ+0uZ0UK4KstjvEkNBZ0xxhBmxunwNg8582SVyyW7txQmSXibtm8fC4o82LaemPquYskms67bIbJOSrnlug==",
+ "dependencies": {
+ "Asp.Versioning.Http": "10.0.0"
+ }
+ },
+ "Asp.Versioning.Mvc.ApiExplorer": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "H54UOpRoc4RmhQ4RA2lzDz43a/hAu/JN19Yyy/DNmH4XlRxhemfhifJyh9BaXNJOtGa2Dnu2xEeP4VSiTdUdAg==",
+ "dependencies": {
+ "Asp.Versioning.Mvc": "10.0.0"
+ }
+ },
+ "Aspire.Npgsql": {
+ "type": "CentralTransitive",
+ "requested": "[13.2.4, )",
+ "resolved": "13.2.4",
+ "contentHash": "FfKx0Jzv6n1VelJcuRaSmLvyIUjm9x4AAk3Yq5LMlLsORLDi4ABAev8Bns+5075qUt2fF6A2zYPcXVlTiAZemg==",
+ "dependencies": {
+ "AspNetCore.HealthChecks.NpgSql": "9.0.0",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.5",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.5",
+ "Microsoft.Extensions.Options": "10.0.5",
+ "Microsoft.Extensions.Primitives": "10.0.5",
+ "Npgsql.DependencyInjection": "10.0.1",
+ "Npgsql.OpenTelemetry": "10.0.1",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3"
+ }
+ },
+ "AspNetCore.HealthChecks.NpgSql": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
+ "Npgsql": "8.0.3"
+ }
+ },
+ "AspNetCore.HealthChecks.Redis": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
+ "StackExchange.Redis": "2.7.4"
+ }
+ },
+ "Azure.Monitor.OpenTelemetry.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.4.0, )",
+ "resolved": "1.4.0",
+ "contentHash": "Zs9wBCBLkm/8Fz97GfRtbuhgd4yPlM8RKxaL6owlW2KcmO8kMqjNK/2riR5DUF5ck8KloFsUg+cuGTDmIHlqww==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Azure.Monitor.OpenTelemetry.Exporter": "1.5.0",
+ "OpenTelemetry.Extensions.Hosting": "1.14.0",
+ "OpenTelemetry.Instrumentation.AspNetCore": "1.14.0",
+ "OpenTelemetry.Instrumentation.Http": "1.14.0"
+ }
+ },
+ "Dapper": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.72, )",
+ "resolved": "2.1.72",
+ "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
+ },
+ "EFCore.NamingConventions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.1, )",
+ "resolved": "10.0.1",
+ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
+ }
+ },
+ "FluentValidation": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.1, )",
+ "resolved": "12.1.1",
+ "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
+ },
+ "FluentValidation.DependencyInjectionExtensions": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.1, )",
+ "resolved": "12.1.1",
+ "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==",
+ "dependencies": {
+ "FluentValidation": "12.1.1",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0"
+ }
+ },
+ "Hangfire.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.8.23, )",
+ "resolved": "1.8.23",
+ "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==",
+ "dependencies": {
+ "Hangfire.NetCore": "[1.8.23]"
+ }
+ },
+ "Hangfire.Core": {
+ "type": "CentralTransitive",
+ "requested": "[1.8.23, )",
+ "resolved": "1.8.23",
+ "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==",
+ "dependencies": {
+ "Newtonsoft.Json": "11.0.1"
+ }
+ },
+ "Hangfire.PostgreSql": {
+ "type": "CentralTransitive",
+ "requested": "[1.21.1, )",
+ "resolved": "1.21.1",
+ "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==",
+ "dependencies": {
+ "Dapper": "2.0.123",
+ "Dapper.AOT": "1.0.48",
+ "Hangfire.Core": "1.8.0",
+ "Npgsql": "6.0.11"
+ }
+ },
+ "Microsoft.AspNetCore.Authentication.JwtBearer": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "g8klpd7OFJfJOq1EJKcBO8C8I8Dp0QUWoKDPUvvJYe+xunVyBHq6YxfF2CAc6+rkniV25iaWl+6RK87c25n4lA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
+ }
+ },
+ "Microsoft.AspNetCore.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "vKiAcGXG0BwNVw3bOjjWRLnp9tR18dR7MiwpvC94h0yFS+zfnzGHzS/JmmgwUdRixrGxrlIMRAWrVc+2DfAGlg==",
+ "dependencies": {
+ "Microsoft.OpenApi": "2.0.0"
+ }
+ },
+ "Microsoft.AspNetCore.TestHost": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "2UM9EtTmX6yF6Efa6WOO7wmHz2kPksmnzPmMwveuOGJQwbtNg5wKGj7usGLr8Ve3AMhIAc2yqyRXt1xNsed3hg=="
+ },
+ "Microsoft.Build.Framework": {
+ "type": "CentralTransitive",
+ "requested": "[18.0.2, )",
+ "resolved": "18.0.2",
+ "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "G6yclVO5/csPzzsymV0SemY2NDqE31CP5M3jprF5IuO9wJsh4aUOfYD8HCLuDmM1D1CfReegVic48O2r79d46Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.7",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.7",
+ "Microsoft.Extensions.Caching.Memory": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Design": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "8UMWkJdfwN/tyEZS6zd0s55zE5zaG4owldh+E91vXitHmux3FTP6Hjhgk6RL9Sv+TdO4FMERQIT6VzBtRrb1AQ==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "18.0.2",
+ "Microsoft.CodeAnalysis.CSharp": "5.0.0",
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.7",
+ "Microsoft.Extensions.Caching.Memory": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.DependencyModel": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Mono.TextTemplating": "3.0.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "midwPufIwXhOJcVhaZpCZGNbjy2QoPfHI+70nw2dGcoULEW9DybMvMPYkRjOJV0eI46a1oVFhU4lFYDEx6YUbg==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.7",
+ "Microsoft.Extensions.Caching.Memory": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Caching.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "pUDgQKEqNUFlerDIFRg7zzoDVRPEWIG7nR40h8Gzg8RXza4Ry0lWZ7u91bmwu3iUDCxw3Dv6TLHVFoAgY0gy7Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Caching.Hybrid": {
+ "type": "CentralTransitive",
+ "requested": "[10.5.0, )",
+ "resolved": "10.5.0",
+ "contentHash": "INkOmE/6q6txxCS45A9HfY8dCqqjTMJfGzr3cNoMwuZpHVSr0JhMfgr/QNm9BvtvyzsyiK+q7yhCn57fBmoy9Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.6",
+ "Microsoft.Extensions.Caching.Memory": "10.0.6",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.6",
+ "Microsoft.Extensions.Options": "10.0.6"
+ }
+ },
+ "Microsoft.Extensions.Caching.StackExchangeRedis": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "TvD0totA8qeyrZ2YeY/qRNgYBy4BvO6dG59ziJDLVnLH4s2jeLUFEFcgA3xzqPhCMMbuz9bJTRwHxkZ/7c87jQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "StackExchange.Redis": "2.7.27"
+ }
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "wZbGh7J8R1vXN525O6d8dlcDTxhRTnd5MyW4LdfP5S0tSnTwTCseYSrq6g0Mxh7W9xn8P/2xPuf0D/m6k2dy2w==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "8bS1qIaRivny+WX+49pmeJ6iAylbtX8C0DLEcCQWZjdxQvLqaMssXiGD9P/6pYElrHbK5/nAHmjbQ8STqdMYeg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "TWto3imA+mJMLZI+5sbgLiFFoOFNFkizQYNaC5jTuiHKn3diwm1RN7mWDOEZN9kG2bixw7IvgpvtUG5/teSRzA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Json": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "64dimvyyKk0dbUbrLg/YCv4ugJ4sVz2aXLwfvZwR1EC4tJqW9ru/oVRcXwoJRa2lQGXtYtlpk4maWOeIb48tQw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "gCglFg/9Chu3lyJNytRuQAYM3mXQKNs1i01Cz2bc545QaHQ+LbBb4O5UCfu968Gro3ZVSOZ/ktilmPcaUSGSZA=="
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "uJ9JP677y+uy+C0vtaSfi7XXgFAdz8DhU3M9lwwIXDfQKcyQ0yxM9DVYa0NXDtdVTYA2eBUtVFZ8LY0GCdeE/w==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Hosting": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "M/vBpfWcschvS2EUeq7cHfscsxabiGTptXwV7GeSueovGiSoNjyo1j5PMcWuOAAQrRW3nRqxZk8NeumrmpzUBg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.7",
+ "Microsoft.Extensions.Configuration.CommandLine": "10.0.7",
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.7",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.7",
+ "Microsoft.Extensions.Configuration.Json": "10.0.7",
+ "Microsoft.Extensions.Configuration.UserSecrets": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Diagnostics": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.7",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.7",
+ "Microsoft.Extensions.Logging.Console": "10.0.7",
+ "Microsoft.Extensions.Logging.Debug": "10.0.7",
+ "Microsoft.Extensions.Logging.EventLog": "10.0.7",
+ "Microsoft.Extensions.Logging.EventSource": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "5s8d6qC6EA8UOI4wR/+zlsq7SXttJMRb9d7zvVZ7+bE3CQEfVtC9ITUDCommm87R1zzj6WJBbCnztuIJXnP3DA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Http": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "1wbd+RPhRo3hJKNJhdGEO5ls0LGe55Ho4BUjlFtRUrWxDVVBd7g0Ydq9fbNy86pmvx/j7AGcSPo7YNCo1IRI6Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Diagnostics": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Http.Polly": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "pcUsPoqMHvOp+QJsLA/Hlg/W+IBnAoUXKEBc7FqMcY0sUez15DOKXtbEo81TvHL9xwjWQcF3ZMayNpcvpI7Bqg==",
+ "dependencies": {
+ "Microsoft.Extensions.Http": "10.0.7",
+ "Polly": "7.2.4",
+ "Polly.Extensions.Http": "3.0.0"
+ }
+ },
+ "Microsoft.Extensions.Http.Resilience": {
+ "type": "CentralTransitive",
+ "requested": "[10.5.0, )",
+ "resolved": "10.5.0",
+ "contentHash": "81rw+wjFFP5jREOERb1PHIPvBNFtE6NXO8bsLTSCET2UZWxj7cwrpzcI3l07tOpHEprYmruZAF3kZEar7uG4Iw==",
+ "dependencies": {
+ "Microsoft.Extensions.Http.Diagnostics": "10.5.0",
+ "Microsoft.Extensions.ObjectPool": "10.0.6",
+ "Microsoft.Extensions.Resilience": "10.5.0"
+ }
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "hOeRIQ63GkgiYCB/MIFp+LQs8aXpJXpB55t6Aj37ab7t2/6WeFcPXxYM9hdy/o5tffzwf8mhqzLJP6mjGYCxjw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Logging.Configuration": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "7BBnoGF37USiu7j434put9mDp7EjdlNDIZsR4vHfC1FbLZeLqiWjgJbeEtF0p59Ryqt8AtraHawf0ZKbe5jibg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.7",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.7",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Logging.Console": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "DA++Es6v6W0HfrOrw+K8WyN6jNnZHp640PDdEvl8yfeVmgflKdn6vSSFvufNUSOuY+M2ZaSUgfY+jUKtNpXcCw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging": "10.0.7",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.7",
+ "Microsoft.Extensions.Options": "10.0.7"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7",
+ "Microsoft.Extensions.Primitives": "10.0.7"
+ }
+ },
+ "Microsoft.FeatureManagement.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[4.5.0, )",
+ "resolved": "4.5.0",
+ "contentHash": "CafI8Ne0xuXRNzHMoRGAX4WRvLxpTE7RceBncgME/fRcPD2KwYrxfZpLyJ/kVfYt8uvHkC2xy0VlStj687mVoA==",
+ "dependencies": {
+ "Microsoft.FeatureManagement": "4.5.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols": {
+ "type": "CentralTransitive",
+ "requested": "[8.16.0, )",
+ "resolved": "8.16.0",
+ "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": {
+ "type": "CentralTransitive",
+ "requested": "[8.16.0, )",
+ "resolved": "8.16.0",
+ "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols": "8.16.0",
+ "System.IdentityModel.Tokens.Jwt": "8.16.0"
+ }
+ },
+ "Microsoft.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[2.7.3, )",
+ "resolved": "2.7.3",
+ "contentHash": "riS56czrBUgnvTmJqgXXiKUuTZisnvFfi4BzW9vGcjvTMUPsbCrdG5Nohwsx7G+pVwXgZIQOM+MnT/xgT7bisA=="
+ },
+ "Newtonsoft.Json": {
+ "type": "CentralTransitive",
+ "requested": "[13.0.4, )",
+ "resolved": "13.0.4",
+ "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
+ },
+ "Npgsql": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.2, )",
+ "resolved": "10.0.2",
+ "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.0"
+ }
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.1, )",
+ "resolved": "10.0.1",
+ "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
+ "Npgsql": "10.0.2"
+ }
+ },
+ "OpenTelemetry.Exporter.Console": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "QBGOoPwLHDXX+hXeUpspOjsqEn4vMkLw672QN+MzVWFBzjf625DdxLxhzowS1J/dRtW93U34rRbJec+4808fkg==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Extensions.Hosting": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Instrumentation.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.2, )",
+ "resolved": "1.15.2",
+ "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": {
+ "type": "CentralTransitive",
+ "requested": "[1.14.0-beta.2, )",
+ "resolved": "1.14.0-beta.2",
+ "contentHash": "XsxsKgMuwi84TWkPN98H8FLOO/yW8vWIo/lxXQ8kWXastTI58+A4nmlFderFPmpLc+tvyhOGjHDlTK/AXWWOpQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.0",
+ "Microsoft.Extensions.Options": "10.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.14.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Http": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.1, )",
+ "resolved": "1.15.1",
+ "contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.0",
+ "Microsoft.Extensions.Options": "10.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Runtime": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.1, )",
+ "resolved": "1.15.1",
+ "contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
+ "dependencies": {
+ "OpenTelemetry.Api": "[1.15.3, 2.0.0)"
+ }
+ },
+ "Polly": {
+ "type": "CentralTransitive",
+ "requested": "[8.6.6, )",
+ "resolved": "8.6.6",
+ "contentHash": "czKHYJ6uGowPijuZt4kgF4njfGvWxVZ8mKBcrZ9iEtwDe9HKdF0ug6p6TwUG8EHuuufgbDU//rSBFebt5/0Fyw==",
+ "dependencies": {
+ "Polly.Core": "8.6.6"
+ }
+ },
+ "RabbitMQ.Client": {
+ "type": "CentralTransitive",
+ "requested": "[7.2.1, )",
+ "resolved": "7.2.1",
+ "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==",
+ "dependencies": {
+ "System.Threading.RateLimiting": "8.0.0"
+ }
+ },
+ "Rebus": {
+ "type": "CentralTransitive",
+ "requested": "[8.9.2, )",
+ "resolved": "8.9.2",
+ "contentHash": "JyiO5vkH76wxLKcgXle7ewZ7rfIg+/L8/EFJY8npRsI1QwW8YprZTQX7EBbIuBqfeaqUra+2/TEPen4Nx+PU6A==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.4"
+ }
+ },
+ "Rebus.RabbitMq": {
+ "type": "CentralTransitive",
+ "requested": "[10.1.1, )",
+ "resolved": "10.1.1",
+ "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==",
+ "dependencies": {
+ "RabbitMq.Client": "7.1.2",
+ "rebus": "8.9.0"
+ }
+ },
+ "Rebus.ServiceProvider": {
+ "type": "CentralTransitive",
+ "requested": "[10.7.2, )",
+ "resolved": "10.7.2",
+ "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)",
+ "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)",
+ "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)",
+ "Rebus": "8.9.0"
+ }
+ },
+ "Scrutor": {
+ "type": "CentralTransitive",
+ "requested": "[7.0.0, )",
+ "resolved": "7.0.0",
+ "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
+ "Microsoft.Extensions.DependencyModel": "10.0.0"
+ }
+ },
+ "Serilog": {
+ "type": "CentralTransitive",
+ "requested": "[4.3.1, )",
+ "resolved": "4.3.1",
+ "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ=="
+ },
+ "Serilog.AspNetCore": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==",
+ "dependencies": {
+ "Serilog": "4.3.0",
+ "Serilog.Extensions.Hosting": "10.0.0",
+ "Serilog.Formatting.Compact": "3.0.0",
+ "Serilog.Settings.Configuration": "10.0.0",
+ "Serilog.Sinks.Console": "6.1.1",
+ "Serilog.Sinks.Debug": "3.0.0",
+ "Serilog.Sinks.File": "7.0.0"
+ }
+ },
+ "Serilog.Enrichers.Environment": {
+ "type": "CentralTransitive",
+ "requested": "[3.0.1, )",
+ "resolved": "3.0.1",
+ "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Enrichers.Process": {
+ "type": "CentralTransitive",
+ "requested": "[3.0.0, )",
+ "resolved": "3.0.0",
+ "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Enrichers.Thread": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.0, )",
+ "resolved": "4.0.0",
+ "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Settings.Configuration": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.0, )",
+ "resolved": "10.0.0",
+ "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Binder": "10.0.0",
+ "Microsoft.Extensions.DependencyModel": "10.0.0",
+ "Serilog": "4.3.0"
+ }
+ },
+ "Serilog.Sinks.Console": {
+ "type": "CentralTransitive",
+ "requested": "[6.1.1, )",
+ "resolved": "6.1.1",
+ "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
+ "Serilog.Sinks.Seq": {
+ "type": "CentralTransitive",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==",
+ "dependencies": {
+ "Serilog": "4.2.0",
+ "Serilog.Sinks.File": "6.0.0"
+ }
+ },
+ "System.IdentityModel.Tokens.Jwt": {
+ "type": "CentralTransitive",
+ "requested": "[8.17.0, )",
+ "resolved": "8.17.0",
+ "contentHash": "nKikRYheDeSaXA3wGr2otwaiRFygBa25m+hc7MEomZVIEWZvKVqd8wgP9yn+8QpLRGgw//dUs4LErGx9gtVmAA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "8.17.0",
+ "Microsoft.IdentityModel.Tokens": "8.17.0"
+ }
+ },
+ "System.IO.Hashing": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.7, )",
+ "resolved": "10.0.7",
+ "contentHash": "6hsjdSr4VOXSOnhALkYplHpAxnTG1J33YN42IB6nH2fEg4QnJqrZ4Ft+qn7mkrKAOYC8pCSFYwVWw6rQbmwgLQ=="
+ },
+ "Yarp.ReverseProxy": {
+ "type": "CentralTransitive",
+ "requested": "[2.3.0, )",
+ "resolved": "2.3.0",
+ "contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
+ "dependencies": {
+ "System.IO.Hashing": "8.0.0"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs b/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs
index e9d48489c..f2f5fd6f3 100644
--- a/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs
+++ b/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs
@@ -33,6 +33,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.FeatureManagement;
+using Moq;
using Xunit;
using System.Runtime.CompilerServices;
@@ -124,7 +126,10 @@ public async ValueTask InitializeAsync()
var wireMockUrl = _wireMockFixture!.BaseUrl;
- // Configurar URLs do WireMock nos provedores de CEP específicos para esta instância
+ builder.UseSetting("FeatureManagement:GeographicRestriction", "true");
+ builder.UseSetting("GeographicRestriction:Enabled", "true");
+ builder.UseSetting("GeographicRestriction:FailOpen", "true");
+
builder.UseSetting("Locations:ExternalApis:ViaCep:BaseUrl", wireMockUrl);
builder.UseSetting("Locations:ExternalApis:BrasilApi:BaseUrl", wireMockUrl);
builder.UseSetting("Locations:ExternalApis:OpenCep:BaseUrl", wireMockUrl);
@@ -145,20 +150,20 @@ public async ValueTask InitializeAsync()
["Messaging:Provider"] = "Mock",
["Keycloak:Enabled"] = "false",
["FeatureManagement:GeographicRestriction"] = "true",
- ["Locations:ExternalApis:ViaCep:BaseUrl"] = wireMockUrl,
- ["Locations:ExternalApis:BrasilApi:BaseUrl"] = wireMockUrl,
- ["Locations:ExternalApis:OpenCep:BaseUrl"] = wireMockUrl,
- ["Locations:ExternalApis:Nominatim:BaseUrl"] = wireMockUrl,
- ["Locations:ExternalApis:IBGE:BaseUrl"] = $"{wireMockUrl}/api/v1/localidades",
+ ["GeographicRestriction:Enabled"] = "true",
+ ["GeographicRestriction:FailOpen"] = "true",
["GeographicRestriction:AllowedCities:0"] = "Muriaé",
["GeographicRestriction:AllowedCities:1"] = "Itaperuna",
["GeographicRestriction:AllowedCities:2"] = "Linhares",
["GeographicRestriction:AllowedStates:0"] = "MG",
["GeographicRestriction:AllowedStates:1"] = "RJ",
["GeographicRestriction:AllowedStates:2"] = "ES",
- ["Cache:Enabled"] = "false",
- ["RateLimit:Enabled"] = "false",
- ["AdvancedRateLimit:General:Enabled"] = "false"
+ ["Locations:ExternalApis:ViaCep:BaseUrl"] = wireMockUrl,
+ ["Locations:ExternalApis:BrasilApi:BaseUrl"] = wireMockUrl,
+ ["Locations:ExternalApis:OpenCep:BaseUrl"] = wireMockUrl,
+ ["Locations:ExternalApis:Nominatim:BaseUrl"] = wireMockUrl,
+ ["Locations:ExternalApis:IBGE:BaseUrl"] = $"{wireMockUrl}/api/v1/localidades",
+ ["Cache:Enabled"] = "false"
});
});
builder.ConfigureServices(services =>
@@ -199,7 +204,7 @@ public async ValueTask InitializeAsync()
// Register dummy Stripe client to satisfy DI validation
services.AddSingleton(new Stripe.StripeClient("sk_test_dummy"));
- services.AddHttpContextAccessor();
+services.AddHttpContextAccessor();
if (UseMockGeographicValidation)
{
diff --git a/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs
index 6112a940d..ea6ccb50b 100644
--- a/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs
+++ b/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs
@@ -64,17 +64,18 @@ public async Task GetProviders_WhenBlockedCity_ShouldReturn451()
var json = JsonSerializer.Deserialize(content);
- // Verify all expected fields are present
- json.TryGetProperty("error", out var errorProp).Should().BeTrue($"Missing 'error' property. JSON: {content}");
- json.TryGetProperty("detail", out var detailProp).Should().BeTrue($"Missing 'detail' property. JSON: {content}");
+ // Verify all expected fields are present (middleware response format)
+ json.TryGetProperty("message", out var messageProp).Should().BeTrue($"Missing 'message' property. JSON: {content}");
+ json.TryGetProperty("userLocation", out var locationProp).Should().BeTrue($"Missing 'userLocation' property. JSON: {content}");
json.TryGetProperty("allowedCities", out var citiesProp).Should().BeTrue($"Missing 'allowedCities' property. JSON: {content}");
- json.TryGetProperty("yourLocation", out var locationProp).Should().BeTrue($"Missing 'yourLocation' property. JSON: {content}");
+ json.TryGetProperty("allowedStates", out var statesProp).Should().BeTrue($"Missing 'allowedStates' property. JSON: {content}");
- errorProp.GetString().Should().Be("geographic_restriction");
- detailProp.GetString().Should().Contain("Muriaé");
+ messageProp.GetString().Should().Contain("Muriaé");
citiesProp.GetArrayLength().Should().BeGreaterThan(0, "should have at least one allowed city");
locationProp.TryGetProperty("city", out var cityProp).Should().BeTrue();
cityProp.GetString().Should().Be("São Paulo");
+ locationProp.TryGetProperty("state", out var stateProp).Should().BeTrue();
+ stateProp.GetString().Should().Be("SP");
}
finally
{
diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs
index 381da29e7..da042db16 100644
--- a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs
+++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs
@@ -99,14 +99,20 @@ public async Task GeographicRestriction_WhenIbgeUnavailableAndCityNotAllowed_Sho
response.StatusCode.Should().Be(System.Net.HttpStatusCode.UnavailableForLegalReasons,
$"Expected 451 but got {(int)response.StatusCode}. Response body: {content}");
- // Verify error payload structure
+ // Verify error payload structure (middleware response format)
var json = System.Text.Json.JsonSerializer.Deserialize(content);
- json.GetProperty("error").GetString().Should().Be("geographic_restriction");
- json.GetProperty("yourLocation").GetProperty("city").GetString().Should().Be("Rio de Janeiro");
- json.GetProperty("yourLocation").GetProperty("state").GetString().Should().Be("RJ");
- json.GetProperty("allowedCities").GetArrayLength().Should().BeGreaterThan(0);
- json.GetProperty("allowedStates").GetArrayLength().Should().BeGreaterThan(0);
+ json.TryGetProperty("message", out _).Should().BeTrue($"Missing 'message' property. JSON: {content}");
+ json.TryGetProperty("userLocation", out var locationProp).Should().BeTrue($"Missing 'userLocation' property. JSON: {content}");
+ json.TryGetProperty("allowedCities", out var citiesProp).Should().BeTrue($"Missing 'allowedCities' property. JSON: {content}");
+ json.TryGetProperty("allowedStates", out var statesProp).Should().BeTrue($"Missing 'allowedStates' property. JSON: {content}");
+
+ locationProp.TryGetProperty("city", out var cityProp).Should().BeTrue();
+ cityProp.GetString().Should().Be("Rio de Janeiro");
+ locationProp.TryGetProperty("state", out var stateProp).Should().BeTrue();
+ stateProp.GetString().Should().Be("RJ");
+ citiesProp.GetArrayLength().Should().BeGreaterThan(0);
+ statesProp.GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
diff --git a/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json b/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json
index 788d1d1a4..f450e6d31 100644
--- a/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json
+++ b/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json
@@ -46,6 +46,8 @@
"GeographicRestriction": true
},
"GeographicRestriction": {
+ "Enabled": true,
+ "FailOpen": false,
"AllowedStates": [ "MG", "RJ", "ES" ],
"AllowedCities": [
"Muriaé",
diff --git a/tests/MeAjudaAi.Integration.Tests/packages.lock.json b/tests/MeAjudaAi.Integration.Tests/packages.lock.json
index 376a1d994..0529cda3b 100644
--- a/tests/MeAjudaAi.Integration.Tests/packages.lock.json
+++ b/tests/MeAjudaAi.Integration.Tests/packages.lock.json
@@ -2542,6 +2542,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",
diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/ServiceDefaults/ExtensionsTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/ServiceDefaults/ExtensionsTests.cs
index e16ea1d64..c994bb7bc 100644
--- a/tests/MeAjudaAi.Shared.Tests/Unit/ServiceDefaults/ExtensionsTests.cs
+++ b/tests/MeAjudaAi.Shared.Tests/Unit/ServiceDefaults/ExtensionsTests.cs
@@ -1236,7 +1236,8 @@ public void AddServiceDefaults_ShouldRegisterFeatureManagerSnapshot()
var host = ((HostApplicationBuilder)builder).Build();
// Assert
- var snapshot = host.Services.GetService();
+ using var scope = host.Services.CreateScope();
+ var snapshot = scope.ServiceProvider.GetService();
snapshot.Should().NotBeNull();
}
diff --git a/tests/MeAjudaAi.Shared.Tests/packages.lock.json b/tests/MeAjudaAi.Shared.Tests/packages.lock.json
index a7151d311..578d6d928 100644
--- a/tests/MeAjudaAi.Shared.Tests/packages.lock.json
+++ b/tests/MeAjudaAi.Shared.Tests/packages.lock.json
@@ -1131,6 +1131,7 @@
"MeAjudaAi.ServiceDefaults": "[1.0.0, )",
"MeAjudaAi.Shared": "[1.0.0, )",
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.7, )",
+ "Microsoft.FeatureManagement.AspNetCore": "[4.5.0, )",
"Serilog.AspNetCore": "[10.0.0, )",
"Serilog.Sinks.Seq": "[9.0.0, )",
"Swashbuckle.AspNetCore": "[10.1.7, )",