save checkpoint
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
@@ -17,6 +18,8 @@ public sealed class GatewayOptions
|
||||
public GatewayOpenApiOptions OpenApi { get; set; } = new();
|
||||
|
||||
public GatewayHealthOptions Health { get; set; } = new();
|
||||
|
||||
public List<StellaOpsRoute> Routes { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GatewayNodeOptions
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
public static class GatewayOptionsValidator
|
||||
@@ -35,5 +38,85 @@ public static class GatewayOptionsValidator
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.StaleThreshold, TimeSpan.FromSeconds(30));
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
|
||||
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
|
||||
|
||||
ValidateRoutes(options.Routes);
|
||||
}
|
||||
|
||||
private static void ValidateRoutes(List<StellaOpsRoute> routes)
|
||||
{
|
||||
for (var i = 0; i < routes.Count; i++)
|
||||
{
|
||||
var route = routes[i];
|
||||
var prefix = $"Route[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(route.Path))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: Path must not be empty.");
|
||||
}
|
||||
|
||||
if (route.IsRegex)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new Regex(route.Path, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: Path is not a valid regex pattern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
switch (route.Type)
|
||||
{
|
||||
case StellaOpsRouteType.ReverseProxy:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo) ||
|
||||
!Uri.TryCreate(route.TranslatesTo, UriKind.Absolute, out var proxyUri) ||
|
||||
(proxyUri.Scheme != "http" && proxyUri.Scheme != "https"))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: ReverseProxy requires a valid HTTP(S) URL in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.StaticFiles:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: StaticFiles requires a directory path in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.StaticFile:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: StaticFile requires a file path in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.WebSocket:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo) ||
|
||||
!Uri.TryCreate(route.TranslatesTo, UriKind.Absolute, out var wsUri) ||
|
||||
(wsUri.Scheme != "ws" && wsUri.Scheme != "wss"))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: WebSocket requires a valid ws:// or wss:// URL in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.NotFoundPage:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: NotFoundPage requires a file path in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.ServerErrorPage:
|
||||
if (string.IsNullOrWhiteSpace(route.TranslatesTo))
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}: ServerErrorPage requires a file path in TranslatesTo.");
|
||||
}
|
||||
break;
|
||||
|
||||
case StellaOpsRouteType.Microservice:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class ErrorPageFallbackMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly string? _notFoundPagePath;
|
||||
private readonly string? _serverErrorPagePath;
|
||||
private readonly ILogger<ErrorPageFallbackMiddleware> _logger;
|
||||
|
||||
public ErrorPageFallbackMiddleware(
|
||||
RequestDelegate next,
|
||||
IEnumerable<StellaOpsRoute> errorRoutes,
|
||||
ILogger<ErrorPageFallbackMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
|
||||
foreach (var route in errorRoutes)
|
||||
{
|
||||
switch (route.Type)
|
||||
{
|
||||
case StellaOpsRouteType.NotFoundPage:
|
||||
_notFoundPagePath = route.TranslatesTo;
|
||||
break;
|
||||
case StellaOpsRouteType.ServerErrorPage:
|
||||
_serverErrorPagePath = route.TranslatesTo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: no error pages configured, skip body wrapping
|
||||
if (_notFoundPagePath is null && _serverErrorPagePath is null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the original response body to detect status codes
|
||||
var originalBody = context.Response.Body;
|
||||
using var memoryStream = new MemoryStream();
|
||||
context.Response.Body = memoryStream;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in pipeline");
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
|
||||
// Check if we need to serve a custom error page
|
||||
if (context.Response.StatusCode == 404 && _notFoundPagePath is not null && memoryStream.Length == 0)
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
await ServeErrorPage(context, _notFoundPagePath, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Response.StatusCode >= 500 && _serverErrorPagePath is not null && memoryStream.Length == 0)
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
await ServeErrorPage(context, _serverErrorPagePath, context.Response.StatusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// No error page override, copy the original response
|
||||
memoryStream.Position = 0;
|
||||
context.Response.Body = originalBody;
|
||||
await memoryStream.CopyToAsync(originalBody, context.RequestAborted);
|
||||
}
|
||||
|
||||
private async Task ServeErrorPage(HttpContext context, string filePath, int statusCode)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("Error page file not found: {FilePath}", filePath);
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
$$"""{"error":"{{(statusCode == 404 ? "not_found" : "internal_server_error")}}","status":{{statusCode}}}""",
|
||||
context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class RouteDispatchMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly StellaOpsRouteResolver _resolver;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<RouteDispatchMiddleware> _logger;
|
||||
private readonly FileExtensionContentTypeProvider _contentTypeProvider = new();
|
||||
|
||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
|
||||
"TE", "Trailers", "Transfer-Encoding", "Upgrade"
|
||||
};
|
||||
|
||||
public RouteDispatchMiddleware(
|
||||
RequestDelegate next,
|
||||
StellaOpsRouteResolver resolver,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<RouteDispatchMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_resolver = resolver;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// System paths (health, metrics, openapi) bypass route dispatch
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var route = _resolver.Resolve(context.Request.Path);
|
||||
if (route is null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (route.Type)
|
||||
{
|
||||
case StellaOpsRouteType.StaticFiles:
|
||||
await HandleStaticFiles(context, route);
|
||||
break;
|
||||
case StellaOpsRouteType.StaticFile:
|
||||
await HandleStaticFile(context, route);
|
||||
break;
|
||||
case StellaOpsRouteType.ReverseProxy:
|
||||
await HandleReverseProxy(context, route);
|
||||
break;
|
||||
case StellaOpsRouteType.WebSocket:
|
||||
await HandleWebSocket(context, route);
|
||||
break;
|
||||
case StellaOpsRouteType.Microservice:
|
||||
await _next(context);
|
||||
break;
|
||||
default:
|
||||
await _next(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStaticFiles(HttpContext context, StellaOpsRoute route)
|
||||
{
|
||||
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||
var relativePath = requestPath;
|
||||
|
||||
if (requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relativePath = requestPath[route.Path.Length..];
|
||||
if (!relativePath.StartsWith('/'))
|
||||
{
|
||||
relativePath = "/" + relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
var directoryPath = route.TranslatesTo!;
|
||||
if (!Directory.Exists(directoryPath))
|
||||
{
|
||||
_logger.LogWarning("StaticFiles directory not found: {Directory}", directoryPath);
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var fileProvider = new PhysicalFileProvider(directoryPath);
|
||||
var fileInfo = fileProvider.GetFileInfo(relativePath);
|
||||
|
||||
if (fileInfo.Exists && !fileInfo.IsDirectory)
|
||||
{
|
||||
await ServeFile(context, fileInfo, relativePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for paths without extensions
|
||||
var spaFallback = route.Headers.TryGetValue("x-spa-fallback", out var spaValue) &&
|
||||
string.Equals(spaValue, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (spaFallback && !System.IO.Path.HasExtension(relativePath))
|
||||
{
|
||||
var indexFile = fileProvider.GetFileInfo("/index.html");
|
||||
if (indexFile.Exists && !indexFile.IsDirectory)
|
||||
{
|
||||
await ServeFile(context, indexFile, "/index.html");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private async Task HandleStaticFile(HttpContext context, StellaOpsRoute route)
|
||||
{
|
||||
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
// StaticFile serves the exact file only at the exact path
|
||||
if (!requestPath.Equals(route.Path, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var filePath = route.TranslatesTo!;
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("StaticFile not found: {File}", filePath);
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var fileName = System.IO.Path.GetFileName(filePath);
|
||||
if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType))
|
||||
{
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = contentType;
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
|
||||
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route)
|
||||
{
|
||||
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||
var remainingPath = requestPath;
|
||||
|
||||
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
remainingPath = requestPath[route.Path.Length..];
|
||||
}
|
||||
|
||||
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
|
||||
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}{context.Request.QueryString}");
|
||||
|
||||
var client = _httpClientFactory.CreateClient("RouteDispatch");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var upstreamRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), upstreamUri);
|
||||
|
||||
// Copy request headers (excluding hop-by-hop)
|
||||
foreach (var header in context.Request.Headers)
|
||||
{
|
||||
if (HopByHopHeaders.Contains(header.Key) ||
|
||||
header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
upstreamRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
|
||||
}
|
||||
|
||||
// Inject configured headers
|
||||
foreach (var (key, value) in route.Headers)
|
||||
{
|
||||
upstreamRequest.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
|
||||
// Copy request body for methods that support it
|
||||
if (context.Request.ContentLength > 0 || context.Request.ContentType is not null)
|
||||
{
|
||||
upstreamRequest.Content = new StreamContent(context.Request.Body);
|
||||
if (context.Request.ContentType is not null)
|
||||
{
|
||||
upstreamRequest.Content.Headers.TryAddWithoutValidation("Content-Type", context.Request.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponseMessage upstreamResponse;
|
||||
try
|
||||
{
|
||||
upstreamResponse = await client.SendAsync(
|
||||
upstreamRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
context.RequestAborted);
|
||||
}
|
||||
catch (TaskCanceledException) when (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||
return;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Reverse proxy upstream request failed for {Upstream}", upstreamUri);
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
using (upstreamResponse)
|
||||
{
|
||||
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
// Copy response headers
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
// Stream response body
|
||||
await using var responseStream = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted);
|
||||
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleWebSocket(HttpContext context, StellaOpsRoute route)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||
var remainingPath = requestPath;
|
||||
|
||||
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
remainingPath = requestPath[route.Path.Length..];
|
||||
}
|
||||
|
||||
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
|
||||
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}");
|
||||
|
||||
using var clientWebSocket = new ClientWebSocket();
|
||||
try
|
||||
{
|
||||
await clientWebSocket.ConnectAsync(upstreamUri, context.RequestAborted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "WebSocket upstream connection failed for {Upstream}", upstreamUri);
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
using var serverWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
|
||||
|
||||
var clientToServer = PumpWebSocket(serverWebSocket, clientWebSocket, cts);
|
||||
var serverToClient = PumpWebSocket(clientWebSocket, serverWebSocket, cts);
|
||||
|
||||
await Task.WhenAny(clientToServer, serverToClient);
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
|
||||
private static async Task PumpWebSocket(
|
||||
WebSocket source,
|
||||
WebSocket destination,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
try
|
||||
{
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
var result = await source.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer),
|
||||
cts.Token);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
if (destination.State == WebSocketState.Open ||
|
||||
destination.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
await destination.CloseAsync(
|
||||
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
|
||||
result.CloseStatusDescription,
|
||||
cts.Token);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (destination.State == WebSocketState.Open)
|
||||
{
|
||||
await destination.SendAsync(
|
||||
new ArraySegment<byte>(buffer, 0, result.Count),
|
||||
result.MessageType,
|
||||
result.EndOfMessage,
|
||||
cts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during shutdown
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// Connection closed unexpectedly
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeFile(HttpContext context, IFileInfo fileInfo, string fileName)
|
||||
{
|
||||
if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType))
|
||||
{
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = contentType;
|
||||
context.Response.ContentLength = fileInfo.Length;
|
||||
|
||||
await using var stream = fileInfo.CreateReadStream();
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Gateway.WebService.Security;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
using StellaOps.Messaging.DependencyInjection;
|
||||
@@ -126,6 +127,19 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
|
||||
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
|
||||
});
|
||||
|
||||
// Route table: resolver + error routes + HTTP client for reverse proxy
|
||||
builder.Services.AddSingleton(new StellaOpsRouteResolver(bootstrapOptions.Routes));
|
||||
builder.Services.AddSingleton<IEnumerable<StellaOpsRoute>>(
|
||||
bootstrapOptions.Routes.Where(r =>
|
||||
r.Type == StellaOpsRouteType.NotFoundPage ||
|
||||
r.Type == StellaOpsRouteType.ServerErrorPage).ToList());
|
||||
builder.Services.AddHttpClient("RouteDispatch")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions);
|
||||
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
|
||||
|
||||
@@ -152,6 +166,12 @@ app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
|
||||
app.UseMiddleware<HealthCheckMiddleware>();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
// WebSocket support (before route dispatch)
|
||||
app.UseWebSockets();
|
||||
|
||||
// Route dispatch for configured routes (static files, reverse proxy, websocket)
|
||||
app.UseMiddleware<RouteDispatchMiddleware>();
|
||||
|
||||
if (bootstrapOptions.OpenApi.Enabled)
|
||||
{
|
||||
app.MapRouterOpenApi();
|
||||
@@ -171,6 +191,9 @@ app.UseWhen(
|
||||
branch.UseMiddleware<RequestRoutingMiddleware>();
|
||||
});
|
||||
|
||||
// Error page fallback (after all other middleware)
|
||||
app.UseMiddleware<ErrorPageFallbackMiddleware>();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Routing;
|
||||
|
||||
public sealed class StellaOpsRouteResolver
|
||||
{
|
||||
private readonly List<(StellaOpsRoute Route, Regex? Pattern)> _routes;
|
||||
|
||||
public StellaOpsRouteResolver(IEnumerable<StellaOpsRoute> routes)
|
||||
{
|
||||
_routes = new List<(StellaOpsRoute, Regex?)>();
|
||||
foreach (var route in routes)
|
||||
{
|
||||
if (route.Type == StellaOpsRouteType.NotFoundPage ||
|
||||
route.Type == StellaOpsRouteType.ServerErrorPage)
|
||||
{
|
||||
// Error page routes don't participate in path resolution
|
||||
continue;
|
||||
}
|
||||
|
||||
Regex? pattern = route.IsRegex
|
||||
? new Regex(route.Path, RegexOptions.Compiled, TimeSpan.FromSeconds(1))
|
||||
: null;
|
||||
|
||||
_routes.Add((route, pattern));
|
||||
}
|
||||
}
|
||||
|
||||
public StellaOpsRoute? Resolve(PathString path)
|
||||
{
|
||||
var pathValue = path.Value ?? string.Empty;
|
||||
|
||||
foreach (var (route, pattern) in _routes)
|
||||
{
|
||||
if (pattern is not null)
|
||||
{
|
||||
if (pattern.IsMatch(pathValue))
|
||||
{
|
||||
return route;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (pathValue.Equals(route.Path, StringComparison.OrdinalIgnoreCase) ||
|
||||
pathValue.StartsWith(route.Path + "/", StringComparison.OrdinalIgnoreCase) ||
|
||||
pathValue.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase) &&
|
||||
route.Path.EndsWith('/'))
|
||||
{
|
||||
return route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,60 @@
|
||||
"StaleThreshold": "30s",
|
||||
"DegradedThreshold": "15s",
|
||||
"CheckInterval": "5s"
|
||||
}
|
||||
},
|
||||
"Routes": [
|
||||
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
|
||||
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local/connect" },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known" },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks" },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
|
||||
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
|
||||
{ "Type": "ReverseProxy", "Path": "/gateway", "TranslatesTo": "http://gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policyEngine", "TranslatesTo": "http://policy-engine.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/concelier", "TranslatesTo": "http://concelier.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/attestor", "TranslatesTo": "http://attestor.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/notify", "TranslatesTo": "http://notify.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/notifier", "TranslatesTo": "http://notifier.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/scheduler", "TranslatesTo": "http://scheduler.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/graph", "TranslatesTo": "http://graph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/integrations", "TranslatesTo": "http://integrations.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/replay", "TranslatesTo": "http://replay.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/exportcenter", "TranslatesTo": "http://exportcenter.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/evidencelocker", "TranslatesTo": "http://evidencelocker.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/signer", "TranslatesTo": "http://signer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/riskengine", "TranslatesTo": "http://riskengine.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/vulnexplorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timeline", "TranslatesTo": "http://timeline.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/symbols", "TranslatesTo": "http://symbols.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/packsregistry", "TranslatesTo": "http://packsregistry.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/registryTokenservice", "TranslatesTo": "http://registry-token.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
|
||||
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
|
||||
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
|
||||
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
public enum StellaOpsRouteType
|
||||
{
|
||||
Microservice,
|
||||
ReverseProxy,
|
||||
StaticFiles,
|
||||
StaticFile,
|
||||
WebSocket,
|
||||
NotFoundPage,
|
||||
ServerErrorPage
|
||||
}
|
||||
|
||||
public sealed class StellaOpsRoute
|
||||
{
|
||||
public StellaOpsRouteType Type { get; set; }
|
||||
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public bool IsRegex { get; set; }
|
||||
|
||||
public string? TranslatesTo { get; set; }
|
||||
|
||||
public Dictionary<string, string> Headers { get; set; } = new();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
@@ -158,4 +159,215 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-url")]
|
||||
[InlineData("ftp://example.com")]
|
||||
public void Validate_ReverseProxy_RequiresValidHttpUrl(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ReverseProxy,
|
||||
Path = "/api",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("ReverseProxy", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReverseProxy_ValidHttpUrl_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ReverseProxy,
|
||||
Path = "/api",
|
||||
TranslatesTo = "http://localhost:5000"
|
||||
});
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_StaticFiles_RequiresDirectoryPath(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/static",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("StaticFiles", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("directory", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_StaticFile_RequiresFilePath(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFile,
|
||||
Path = "/favicon.ico",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("StaticFile", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("file path", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://localhost:8080")]
|
||||
[InlineData("not-a-url")]
|
||||
public void Validate_WebSocket_RequiresWsUrl(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.WebSocket,
|
||||
Path = "/ws",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("WebSocket", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WebSocket_ValidWsUrl_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.WebSocket,
|
||||
Path = "/ws",
|
||||
TranslatesTo = "ws://localhost:8080"
|
||||
});
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_EmptyPath_Throws(string? path)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = path!,
|
||||
TranslatesTo = "service-a"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("Path", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("empty", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_Throws()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "[invalid(regex",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "service-a"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("regex", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRegex_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = @"^/api/v[0-9]+",
|
||||
IsRegex = true,
|
||||
TranslatesTo = "service-a"
|
||||
});
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_NotFoundPage_RequiresFilePath(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.NotFoundPage,
|
||||
Path = "/404",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("NotFoundPage", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("file path", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_ServerErrorPage_RequiresFilePath(string? translatesTo)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ServerErrorPage,
|
||||
Path = "/500",
|
||||
TranslatesTo = translatesTo
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("ServerErrorPage", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("file path", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
@@ -179,6 +180,16 @@ public sealed class GatewayWebApplicationFactory : WebApplicationFactory<Program
|
||||
config.NodeId = "test-gateway-01";
|
||||
config.Environment = "test";
|
||||
});
|
||||
|
||||
// Clear route table so appsettings.json production routes don't interfere
|
||||
var emptyRoutes = Array.Empty<StellaOpsRoute>().ToList();
|
||||
var resolverDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(StellaOpsRouteResolver));
|
||||
if (resolverDescriptor is not null) services.Remove(resolverDescriptor);
|
||||
services.AddSingleton(new StellaOpsRouteResolver(emptyRoutes));
|
||||
|
||||
var errorDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEnumerable<StellaOpsRoute>));
|
||||
if (errorDescriptor is not null) services.Remove(errorDescriptor);
|
||||
services.AddSingleton<IEnumerable<StellaOpsRoute>>(emptyRoutes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Integration;
|
||||
|
||||
public sealed class RouteTableIntegrationTests : IClassFixture<RouteTableWebApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly RouteTableWebApplicationFactory _factory;
|
||||
|
||||
public RouteTableIntegrationTests(RouteTableWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_factory.ConfigureDefaultRoutes();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
// ── StaticFiles tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesFileFromMappedDirectory()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/index.html");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("<h1>Test App</h1>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesNestedFile()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/assets/style.css");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("body { margin: 0; }", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_Returns404ForMissingFile()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/missing.txt");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesCorrectMimeType_Html()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/index.html");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesCorrectMimeType_Css()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/assets/style.css");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesCorrectMimeType_Js()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/assets/app.js");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("javascript", response.Content.Headers.ContentType?.MediaType ?? "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_ServesCorrectMimeType_Json()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/app/data.json");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_SpaFallback_ServesIndexHtml()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// /app has x-spa-fallback=true, so extensionless paths serve index.html
|
||||
var response = await client.GetAsync("/app/some/route");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("<h1>Test App</h1>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFiles_MultipleMappings_IsolatedPaths()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var appResponse = await client.GetAsync("/app/index.html");
|
||||
var docsResponse = await client.GetAsync("/docs/index.html");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, appResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, docsResponse.StatusCode);
|
||||
|
||||
var appContent = await appResponse.Content.ReadAsStringAsync();
|
||||
var docsContent = await docsResponse.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Contains("Test App", appContent);
|
||||
Assert.Contains("Docs", docsContent);
|
||||
}
|
||||
|
||||
// ── StaticFile tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFile_ServesSingleFile()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/favicon.ico");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal("fake-icon-data", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFile_IgnoresSubPaths()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/favicon.ico/extra");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticFile_ServesCorrectContentType()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/favicon.ico");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("image/x-icon", response.Content.Headers.ContentType?.MediaType);
|
||||
}
|
||||
|
||||
// ── ReverseProxy tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_ForwardsRequestToUpstream()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy/echo");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"path\":\"/echo\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_StripsPathPrefix()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy/sub/path");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("proxied", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_ForwardsHeaders()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/proxy/echo");
|
||||
request.Headers.TryAddWithoutValidation("X-Test-Header", "test-value");
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("X-Test-Header", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_ReturnsUpstreamStatusCode_201()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy/status/201");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_ReturnsUpstreamStatusCode_400()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy/status/400");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_ReturnsUpstreamStatusCode_500()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy/status/500");
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_InjectsConfiguredHeaders()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/proxy-headers/echo");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("X-Custom-Route", content);
|
||||
Assert.Contains("injected-value", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReverseProxy_RegexPath_MatchesPattern()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Regex route matches ^/api/v[0-9]+/.* and forwards full path to upstream.
|
||||
// Upstream fallback handler echoes the request back.
|
||||
var response = await client.GetAsync("/api/v2/data");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/api/v2/data", content);
|
||||
}
|
||||
|
||||
// ── Microservice compatibility tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task Microservice_ExistingPipeline_StillWorks_Health()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Microservice_WithRouteTable_NoRegression_Metrics()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/metrics");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
// ── Route resolution tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task RouteResolver_NoMatch_FallsToMicroservicePipeline()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// This path doesn't match any configured route, falls through to microservice pipeline
|
||||
var response = await client.GetAsync("/unmatched/random/path");
|
||||
|
||||
// Without registered microservices, unmatched routes should return 404
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteResolver_ExactPath_TakesPriority()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// /favicon.ico is a StaticFile route (exact match)
|
||||
var response = await client.GetAsync("/favicon.ico");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal("fake-icon-data", content);
|
||||
}
|
||||
|
||||
// ── WebSocket tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_UpgradeSucceeds()
|
||||
{
|
||||
var server = _factory.Server;
|
||||
var wsClient = server.CreateWebSocketClient();
|
||||
|
||||
var ws = await wsClient.ConnectAsync(
|
||||
new Uri(server.BaseAddress, "/ws/ws/echo"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WebSocketState.Open, ws.State);
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_MessageRoundTrip()
|
||||
{
|
||||
var server = _factory.Server;
|
||||
var wsClient = server.CreateWebSocketClient();
|
||||
|
||||
var ws = await wsClient.ConnectAsync(
|
||||
new Uri(server.BaseAddress, "/ws/ws/echo"),
|
||||
CancellationToken.None);
|
||||
|
||||
var message = "Hello WebSocket"u8.ToArray();
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(message),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
|
||||
var buffer = new byte[4096];
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
|
||||
Assert.Equal(WebSocketMessageType.Text, result.MessageType);
|
||||
Assert.True(result.EndOfMessage);
|
||||
var received = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
Assert.Equal("Hello WebSocket", received);
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_BinaryMessage()
|
||||
{
|
||||
var server = _factory.Server;
|
||||
var wsClient = server.CreateWebSocketClient();
|
||||
|
||||
var ws = await wsClient.ConnectAsync(
|
||||
new Uri(server.BaseAddress, "/ws/ws/echo"),
|
||||
CancellationToken.None);
|
||||
|
||||
var binaryData = new byte[] { 0x01, 0x02, 0x03, 0xFF };
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(binaryData),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
|
||||
var buffer = new byte[4096];
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
|
||||
Assert.Equal(WebSocketMessageType.Binary, result.MessageType);
|
||||
Assert.Equal(binaryData.Length, result.Count);
|
||||
Assert.Equal(binaryData, buffer[..result.Count]);
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_CloseHandshake()
|
||||
{
|
||||
var server = _factory.Server;
|
||||
var wsClient = server.CreateWebSocketClient();
|
||||
|
||||
var ws = await wsClient.ConnectAsync(
|
||||
new Uri(server.BaseAddress, "/ws/ws/echo"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(WebSocketState.Open, ws.State);
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None);
|
||||
|
||||
Assert.Equal(WebSocketState.Closed, ws.State);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Integration;
|
||||
|
||||
public sealed class RouteTableWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private string _testContentRoot = null!;
|
||||
private WebApplication? _upstreamApp;
|
||||
private string _upstreamBaseUrl = null!;
|
||||
private string _upstreamWsUrl = null!;
|
||||
|
||||
public string TestContentRoot => _testContentRoot;
|
||||
public string UpstreamBaseUrl => _upstreamBaseUrl;
|
||||
|
||||
public List<StellaOpsRoute> Routes { get; } = new();
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_testContentRoot = Path.Combine(Path.GetTempPath(), "stella-route-tests-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(_testContentRoot);
|
||||
|
||||
// Create static file test content
|
||||
var appDir = Path.Combine(_testContentRoot, "app");
|
||||
Directory.CreateDirectory(appDir);
|
||||
var assetsDir = Path.Combine(appDir, "assets");
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(appDir, "index.html"),
|
||||
"<!DOCTYPE html><html><body><h1>Test App</h1></body></html>");
|
||||
await File.WriteAllTextAsync(Path.Combine(assetsDir, "style.css"),
|
||||
"body { margin: 0; }");
|
||||
await File.WriteAllTextAsync(Path.Combine(assetsDir, "app.js"),
|
||||
"console.log('test');");
|
||||
await File.WriteAllTextAsync(Path.Combine(appDir, "data.json"),
|
||||
"""{"key":"value"}""");
|
||||
|
||||
// Create a second static directory for isolation tests
|
||||
var docsDir = Path.Combine(_testContentRoot, "docs");
|
||||
Directory.CreateDirectory(docsDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(docsDir, "index.html"),
|
||||
"<!DOCTYPE html><html><body><h1>Docs</h1></body></html>");
|
||||
|
||||
// Create single static file
|
||||
await File.WriteAllTextAsync(Path.Combine(_testContentRoot, "favicon.ico"),
|
||||
"fake-icon-data");
|
||||
|
||||
// Create error pages
|
||||
await File.WriteAllTextAsync(Path.Combine(_testContentRoot, "404.html"),
|
||||
"<!DOCTYPE html><html><body><h1>Custom 404</h1></body></html>");
|
||||
await File.WriteAllTextAsync(Path.Combine(_testContentRoot, "500.html"),
|
||||
"<!DOCTYPE html><html><body><h1>Custom 500</h1></body></html>");
|
||||
|
||||
// Start upstream test server for reverse proxy and websocket tests
|
||||
await StartUpstreamServer();
|
||||
}
|
||||
|
||||
public new async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_upstreamApp is not null)
|
||||
{
|
||||
await _upstreamApp.StopAsync();
|
||||
await _upstreamApp.DisposeAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_testContentRoot))
|
||||
{
|
||||
Directory.Delete(_testContentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task StartUpstreamServer()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
_upstreamApp = builder.Build();
|
||||
|
||||
// Echo endpoint
|
||||
_upstreamApp.MapGet("/echo", (HttpContext ctx) =>
|
||||
{
|
||||
var headers = new Dictionary<string, string>();
|
||||
foreach (var h in ctx.Request.Headers)
|
||||
{
|
||||
headers[h.Key] = h.Value.ToString();
|
||||
}
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
method = ctx.Request.Method,
|
||||
path = ctx.Request.Path.Value,
|
||||
query = ctx.Request.QueryString.Value,
|
||||
headers
|
||||
});
|
||||
});
|
||||
|
||||
// Data endpoint
|
||||
_upstreamApp.MapGet("/sub/path", () => Results.Json(new { result = "proxied" }));
|
||||
|
||||
// Status code endpoint
|
||||
_upstreamApp.Map("/status/{code:int}", (int code) => Results.StatusCode(code));
|
||||
|
||||
// Slow endpoint (for timeout tests)
|
||||
_upstreamApp.MapGet("/slow", async (CancellationToken ct) =>
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(60), ct); } catch (OperationCanceledException) { }
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
// POST endpoint that echoes body
|
||||
_upstreamApp.MapPost("/echo-body", async (HttpContext ctx) =>
|
||||
{
|
||||
using var reader = new StreamReader(ctx.Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
return Results.Text(body, "text/plain");
|
||||
});
|
||||
|
||||
// Catch-all echo endpoint (for regex route tests)
|
||||
_upstreamApp.MapFallback((HttpContext ctx) =>
|
||||
{
|
||||
var headers = new Dictionary<string, string>();
|
||||
foreach (var h in ctx.Request.Headers)
|
||||
{
|
||||
headers[h.Key] = h.Value.ToString();
|
||||
}
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
method = ctx.Request.Method,
|
||||
path = ctx.Request.Path.Value,
|
||||
query = ctx.Request.QueryString.Value,
|
||||
headers,
|
||||
fallback = true
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket echo endpoint
|
||||
_upstreamApp.UseWebSockets();
|
||||
_upstreamApp.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path == "/ws/echo" && context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||
var buffer = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await ws.CloseAsync(
|
||||
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
|
||||
result.CloseStatusDescription,
|
||||
CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(buffer, 0, result.Count),
|
||||
result.MessageType,
|
||||
result.EndOfMessage,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
});
|
||||
|
||||
await _upstreamApp.StartAsync();
|
||||
var address = _upstreamApp.Urls.First();
|
||||
_upstreamBaseUrl = address;
|
||||
_upstreamWsUrl = address.Replace("http://", "ws://");
|
||||
}
|
||||
|
||||
public void ConfigureDefaultRoutes()
|
||||
{
|
||||
Routes.Clear();
|
||||
|
||||
// StaticFiles route
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/app",
|
||||
TranslatesTo = Path.Combine(_testContentRoot, "app"),
|
||||
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
|
||||
});
|
||||
|
||||
// Second StaticFiles route for isolation tests
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/docs",
|
||||
TranslatesTo = Path.Combine(_testContentRoot, "docs")
|
||||
});
|
||||
|
||||
// StaticFile route
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFile,
|
||||
Path = "/favicon.ico",
|
||||
TranslatesTo = Path.Combine(_testContentRoot, "favicon.ico")
|
||||
});
|
||||
|
||||
// ReverseProxy route
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ReverseProxy,
|
||||
Path = "/proxy",
|
||||
TranslatesTo = _upstreamBaseUrl
|
||||
});
|
||||
|
||||
// ReverseProxy with custom headers
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ReverseProxy,
|
||||
Path = "/proxy-headers",
|
||||
TranslatesTo = _upstreamBaseUrl,
|
||||
Headers = new Dictionary<string, string> { ["X-Custom-Route"] = "injected-value" }
|
||||
});
|
||||
|
||||
// Regex route
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ReverseProxy,
|
||||
Path = @"^/api/v[0-9]+/.*",
|
||||
IsRegex = true,
|
||||
TranslatesTo = _upstreamBaseUrl
|
||||
});
|
||||
|
||||
// WebSocket route
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.WebSocket,
|
||||
Path = "/ws",
|
||||
TranslatesTo = _upstreamWsUrl
|
||||
});
|
||||
|
||||
// Error pages
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.NotFoundPage,
|
||||
Path = "/_error/404",
|
||||
TranslatesTo = Path.Combine(_testContentRoot, "404.html")
|
||||
});
|
||||
|
||||
Routes.Add(new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.ServerErrorPage,
|
||||
Path = "/_error/500",
|
||||
TranslatesTo = Path.Combine(_testContentRoot, "500.html")
|
||||
});
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.Configure<RouterNodeConfig>(config =>
|
||||
{
|
||||
config.Region = "test";
|
||||
config.NodeId = "test-route-table-01";
|
||||
config.Environment = "test";
|
||||
});
|
||||
|
||||
// Override route resolver and error routes for testing
|
||||
var routeList = Routes.ToList();
|
||||
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(StellaOpsRouteResolver));
|
||||
if (descriptor is not null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
services.AddSingleton(new StellaOpsRouteResolver(routeList));
|
||||
|
||||
var errorDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEnumerable<StellaOpsRoute>));
|
||||
if (errorDescriptor is not null)
|
||||
{
|
||||
services.Remove(errorDescriptor);
|
||||
}
|
||||
services.AddSingleton<IEnumerable<StellaOpsRoute>>(
|
||||
routeList.Where(r =>
|
||||
r.Type == StellaOpsRouteType.NotFoundPage ||
|
||||
r.Type == StellaOpsRouteType.ServerErrorPage).ToList());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Routing;
|
||||
|
||||
public sealed class StellaOpsRouteResolverTests
|
||||
{
|
||||
private static StellaOpsRoute MakeRoute(
|
||||
string path,
|
||||
StellaOpsRouteType type = StellaOpsRouteType.Microservice,
|
||||
bool isRegex = false,
|
||||
string? translatesTo = null)
|
||||
{
|
||||
return new StellaOpsRoute
|
||||
{
|
||||
Path = path,
|
||||
Type = type,
|
||||
IsRegex = isRegex,
|
||||
TranslatesTo = translatesTo ?? "http://backend:5000"
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ExactPathMatch_ReturnsRoute()
|
||||
{
|
||||
var route = MakeRoute("/dashboard");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/dashboard"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/dashboard", result.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_PrefixMatch_ReturnsRoute()
|
||||
{
|
||||
var route = MakeRoute("/app");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/app/index.html"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/app", result.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_RegexRoute_Matches()
|
||||
{
|
||||
var route = MakeRoute(@"^/api/v[0-9]+/.*", isRegex: true);
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/api/v2/data"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsRegex);
|
||||
Assert.Equal(@"^/api/v[0-9]+/.*", result.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoMatch_ReturnsNull()
|
||||
{
|
||||
var route = MakeRoute("/dashboard");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/unknown"));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FirstMatchWins()
|
||||
{
|
||||
var firstRoute = MakeRoute("/api", translatesTo: "http://first:5000");
|
||||
var secondRoute = MakeRoute("/api", translatesTo: "http://second:5000");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { firstRoute, secondRoute });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/api/resource"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("http://first:5000", result.TranslatesTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NotFoundPageRoute_IsExcluded()
|
||||
{
|
||||
var notFoundRoute = MakeRoute("/not-found", type: StellaOpsRouteType.NotFoundPage);
|
||||
var resolver = new StellaOpsRouteResolver(new[] { notFoundRoute });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/not-found"));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ServerErrorPageRoute_IsExcluded()
|
||||
{
|
||||
var errorRoute = MakeRoute("/error", type: StellaOpsRouteType.ServerErrorPage);
|
||||
var resolver = new StellaOpsRouteResolver(new[] { errorRoute });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/error"));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CaseInsensitive_Matches()
|
||||
{
|
||||
var route = MakeRoute("/app");
|
||||
var resolver = new StellaOpsRouteResolver(new[] { route });
|
||||
|
||||
var result = resolver.Resolve(new PathString("/APP"));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("/app", result.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EmptyRoutes_ReturnsNull()
|
||||
{
|
||||
var resolver = new StellaOpsRouteResolver(Array.Empty<StellaOpsRoute>());
|
||||
|
||||
var result = resolver.Resolve(new PathString("/anything"));
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user