using System.Security.Claims; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Router.Common.Abstractions; using StellaOps.Router.Common.Models; using StellaOps.Router.Gateway.Authorization; using StellaOps.Router.Gateway.Configuration; using StellaOps.Router.Gateway.Middleware; using StellaOps.Router.Gateway.State; using Xunit; namespace StellaOps.Router.Gateway.Tests; public sealed class MiddlewareErrorScenarioTests { [Fact] public async Task EndpointResolutionMiddleware_WhenNoEndpoint_Returns404StructuredError() { var context = CreateContext(method: "GET", path: "/missing"); var routingState = new InMemoryRoutingState(); var nextCalled = false; var middleware = new EndpointResolutionMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }); await middleware.Invoke(context, routingState); nextCalled.Should().BeFalse(); context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); var body = ReadJson(context); body.GetProperty("error").GetString().Should().Be("Endpoint not found"); body.GetProperty("status").GetInt32().Should().Be(404); body.GetProperty("method").GetString().Should().Be("GET"); body.GetProperty("path").GetString().Should().Be("/missing"); body.GetProperty("traceId").GetString().Should().Be("trace-1"); } [Fact] public async Task RoutingDecisionMiddleware_WhenNoInstances_Returns503StructuredError() { var context = CreateContext(method: "GET", path: "/items"); context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor { ServiceName = "inventory", Version = "1.0.0", Method = "GET", Path = "/items" }; var routingState = new InMemoryRoutingState(); var plugin = new NullRoutingPlugin(); var nextCalled = false; var middleware = new RoutingDecisionMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }); await middleware.Invoke( context, plugin, routingState, Options.Create(new RouterNodeConfig { Region = "eu1", NodeId = "gw-eu1-01" }), Options.Create(new RoutingOptions { DefaultVersion = null })); nextCalled.Should().BeFalse(); context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); var body = ReadJson(context); body.GetProperty("error").GetString().Should().Be("No instances available"); body.GetProperty("status").GetInt32().Should().Be(503); body.GetProperty("service").GetString().Should().Be("inventory"); body.GetProperty("version").GetString().Should().Be("1.0.0"); } [Fact] public async Task AuthorizationMiddleware_WhenMissingClaim_Returns403StructuredError() { var context = CreateContext(method: "GET", path: "/items"); context.User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim("scope", "user")], authenticationType: "test")); context.Items[RouterHttpContextKeys.EndpointDescriptor] = new EndpointDescriptor { ServiceName = "inventory", Version = "1.0.0", Method = "GET", Path = "/items", RequiringClaims = [] }; var claimsStore = new StaticClaimsStore( [new ClaimRequirement { Type = "scope", Value = "admin" }]); var nextCalled = false; var middleware = new AuthorizationMiddleware( _ => { nextCalled = true; return Task.CompletedTask; }, claimsStore, NullLogger.Instance); await middleware.InvokeAsync(context); nextCalled.Should().BeFalse(); context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); var body = ReadJson(context); body.GetProperty("error").GetString().Should().Be("Forbidden"); body.GetProperty("status").GetInt32().Should().Be(403); body.GetProperty("service").GetString().Should().Be("inventory"); body.GetProperty("version").GetString().Should().Be("1.0.0"); body.GetProperty("details").GetProperty("requiredClaimType").GetString().Should().Be("scope"); body.GetProperty("details").GetProperty("requiredClaimValue").GetString().Should().Be("admin"); } [Fact] public async Task GlobalErrorHandlerMiddleware_WhenUnhandledException_Returns500StructuredError() { var context = CreateContext(method: "GET", path: "/boom"); var environment = new TestHostEnvironment { EnvironmentName = Environments.Development }; var middleware = new GlobalErrorHandlerMiddleware( _ => throw new InvalidOperationException("boom"), NullLogger.Instance, environment); await middleware.Invoke(context); context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); var body = ReadJson(context); body.GetProperty("error").GetString().Should().Be("Internal Server Error"); body.GetProperty("status").GetInt32().Should().Be(500); body.GetProperty("message").GetString().Should().Be("boom"); } private static DefaultHttpContext CreateContext(string method, string path, string? queryString = null) { var services = new ServiceCollection(); services.AddLogging(); services.AddOptions(); var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; context.TraceIdentifier = "trace-1"; context.Request.Method = method; context.Request.Path = path; context.Request.QueryString = string.IsNullOrWhiteSpace(queryString) ? QueryString.Empty : new QueryString(queryString); context.Response.Body = new MemoryStream(); return context; } private static JsonElement ReadJson(DefaultHttpContext context) { context.Response.Body.Position = 0; using var doc = JsonDocument.Parse(context.Response.Body); return doc.RootElement.Clone(); } private sealed class NullRoutingPlugin : IRoutingPlugin { public Task ChooseInstanceAsync(RoutingContext context, CancellationToken cancellationToken) { return Task.FromResult(null); } } private sealed class StaticClaimsStore : IEffectiveClaimsStore { private readonly IReadOnlyList _claims; public StaticClaimsStore(IReadOnlyList claims) { _claims = claims; } public IReadOnlyList GetEffectiveClaims(string serviceName, string method, string path) => _claims; public void UpdateFromMicroservice(string serviceName, IReadOnlyList endpoints) { } public void UpdateFromAuthority(IReadOnlyDictionary> overrides) { } public void RemoveService(string serviceName) { } } private sealed class TestHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Production; public string ApplicationName { get; set; } = "StellaOps.Router.Gateway.Tests"; public string ContentRootPath { get; set; } = Environment.CurrentDirectory; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } }