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, )",