save checkpoint

This commit is contained in:
master
2026-02-12 21:02:43 +02:00
parent 5bca406787
commit 9911b7d73c
593 changed files with 174390 additions and 1376 deletions

View File

@@ -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);
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}
}

View File

@@ -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());
});
}
}

View File

@@ -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);
}
}