Add unit tests for RabbitMq and Udp transport servers and clients
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented comprehensive unit tests for RabbitMqTransportServer, covering constructor, disposal, connection management, event handlers, and exception handling.
- Added configuration tests for RabbitMqTransportServer to validate SSL, durable queues, auto-recovery, and custom virtual host options.
- Created unit tests for UdpFrameProtocol, including frame parsing and serialization, header size validation, and round-trip data preservation.
- Developed tests for UdpTransportClient, focusing on connection handling, event subscriptions, and exception scenarios.
- Established tests for UdpTransportServer, ensuring proper start/stop behavior, connection state management, and event handling.
- Included tests for UdpTransportOptions to verify default values and modification capabilities.
- Enhanced service registration tests for Udp transport services in the dependency injection container.
This commit is contained in:
master
2025-12-05 19:01:12 +02:00
parent 53508ceccb
commit cc69d332e3
245 changed files with 22440 additions and 27719 deletions

View File

@@ -1,4 +1,5 @@
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Gateway.WebService.OpenApi;
namespace StellaOps.Gateway.WebService;
@@ -25,4 +26,15 @@ public static class ApplicationBuilderExtensions
return app;
}
/// <summary>
/// Maps OpenAPI endpoints to the application.
/// Should be called before UseGatewayRouter so OpenAPI requests are handled first.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayOpenApi(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGatewayOpenApiEndpoints();
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
@@ -14,17 +15,20 @@ internal sealed class ConnectionManager : IHostedService
private readonly InMemoryTransportServer _transportServer;
private readonly InMemoryConnectionRegistry _connectionRegistry;
private readonly IGlobalRoutingState _routingState;
private readonly IGatewayOpenApiDocumentCache? _openApiCache;
private readonly ILogger<ConnectionManager> _logger;
public ConnectionManager(
InMemoryTransportServer transportServer,
InMemoryConnectionRegistry connectionRegistry,
IGlobalRoutingState routingState,
ILogger<ConnectionManager> logger)
ILogger<ConnectionManager> logger,
IGatewayOpenApiDocumentCache? openApiCache = null)
{
_transportServer = transportServer;
_connectionRegistry = connectionRegistry;
_routingState = routingState;
_openApiCache = openApiCache;
_logger = logger;
}
@@ -55,11 +59,12 @@ internal sealed class ConnectionManager : IHostedService
private Task HandleHelloReceivedAsync(ConnectionState connectionState, HelloPayload payload)
{
_logger.LogInformation(
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints",
"Connection registered: {ConnectionId} from {ServiceName}/{Version} with {EndpointCount} endpoints, {SchemaCount} schemas",
connectionState.ConnectionId,
connectionState.Instance.ServiceName,
connectionState.Instance.Version,
connectionState.Endpoints.Count);
connectionState.Endpoints.Count,
connectionState.Schemas.Count);
// Add the connection to the routing state
_routingState.AddConnection(connectionState);
@@ -67,6 +72,9 @@ internal sealed class ConnectionManager : IHostedService
// Start listening to this connection for frames
_transportServer.StartListeningToConnection(connectionState.ConnectionId);
// Invalidate OpenAPI cache when connections change
_openApiCache?.Invalidate();
return Task.CompletedTask;
}
@@ -94,6 +102,9 @@ internal sealed class ConnectionManager : IHostedService
// Remove from routing state
_routingState.RemoveConnection(connectionId);
// Invalidate OpenAPI cache when connections change
_openApiCache?.Invalidate();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Maps claim requirements to OpenAPI security schemes.
/// </summary>
internal static class ClaimSecurityMapper
{
/// <summary>
/// Generates security schemes from claim requirements.
/// </summary>
/// <param name="endpoints">All endpoint descriptors.</param>
/// <param name="tokenUrl">The OAuth2 token URL.</param>
/// <returns>Security schemes JSON object.</returns>
public static JsonObject GenerateSecuritySchemes(
IEnumerable<EndpointDescriptor> endpoints,
string tokenUrl)
{
var schemes = new JsonObject();
// Always add BearerAuth scheme
schemes["BearerAuth"] = new JsonObject
{
["type"] = "http",
["scheme"] = "bearer",
["bearerFormat"] = "JWT",
["description"] = "JWT Bearer token authentication"
};
// Collect all unique scopes from claims
var scopes = new Dictionary<string, string>();
foreach (var endpoint in endpoints)
{
foreach (var claim in endpoint.RequiringClaims)
{
var scope = claim.Type;
if (!scopes.ContainsKey(scope))
{
scopes[scope] = $"Access scope: {scope}";
}
}
}
// Add OAuth2 scheme if there are any scopes
if (scopes.Count > 0)
{
var scopesObject = new JsonObject();
foreach (var (scope, description) in scopes)
{
scopesObject[scope] = description;
}
schemes["OAuth2"] = new JsonObject
{
["type"] = "oauth2",
["flows"] = new JsonObject
{
["clientCredentials"] = new JsonObject
{
["tokenUrl"] = tokenUrl,
["scopes"] = scopesObject
}
}
};
}
return schemes;
}
/// <summary>
/// Generates security requirement for an endpoint.
/// </summary>
/// <param name="endpoint">The endpoint descriptor.</param>
/// <returns>Security requirement JSON array.</returns>
public static JsonArray GenerateSecurityRequirement(EndpointDescriptor endpoint)
{
var requirements = new JsonArray();
if (endpoint.RequiringClaims.Count == 0)
{
return requirements;
}
var requirement = new JsonObject();
// Always require BearerAuth
requirement["BearerAuth"] = new JsonArray();
// Add OAuth2 scopes
var scopes = new JsonArray();
foreach (var claim in endpoint.RequiringClaims)
{
scopes.Add(claim.Type);
}
if (scopes.Count > 0)
{
requirement["OAuth2"] = scopes;
}
requirements.Add(requirement);
return requirements;
}
}

View File

@@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Caches the generated OpenAPI document with TTL-based expiration.
/// </summary>
internal sealed class GatewayOpenApiDocumentCache : IGatewayOpenApiDocumentCache
{
private readonly IOpenApiDocumentGenerator _generator;
private readonly OpenApiAggregationOptions _options;
private readonly object _lock = new();
private string? _cachedDocument;
private string? _cachedETag;
private DateTime _generatedAt;
private bool _invalidated = true;
public GatewayOpenApiDocumentCache(
IOpenApiDocumentGenerator generator,
IOptions<OpenApiAggregationOptions> options)
{
_generator = generator;
_options = options.Value;
}
/// <inheritdoc />
public (string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument()
{
lock (_lock)
{
var now = DateTime.UtcNow;
var ttl = TimeSpan.FromSeconds(_options.CacheTtlSeconds);
// Check if we need to regenerate
if (_invalidated || _cachedDocument is null || now - _generatedAt > ttl)
{
Regenerate();
}
return (_cachedDocument!, _cachedETag!, _generatedAt);
}
}
/// <inheritdoc />
public void Invalidate()
{
lock (_lock)
{
_invalidated = true;
}
}
private void Regenerate()
{
_cachedDocument = _generator.GenerateDocument();
_cachedETag = ComputeETag(_cachedDocument);
_generatedAt = DateTime.UtcNow;
_invalidated = false;
}
private static string ComputeETag(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"\"{Convert.ToHexString(hash)[..16]}\"";
}
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Caches the generated OpenAPI document with TTL-based expiration.
/// </summary>
public interface IGatewayOpenApiDocumentCache
{
/// <summary>
/// Gets the cached document or regenerates if expired.
/// </summary>
/// <returns>A tuple containing the document JSON, ETag, and generation timestamp.</returns>
(string DocumentJson, string ETag, DateTime GeneratedAt) GetDocument();
/// <summary>
/// Invalidates the cache, forcing regeneration on next access.
/// </summary>
void Invalidate();
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Generates OpenAPI documents from aggregated microservice schemas.
/// </summary>
public interface IOpenApiDocumentGenerator
{
/// <summary>
/// Generates the OpenAPI 3.1.0 document as JSON.
/// </summary>
/// <returns>The OpenAPI document as a JSON string.</returns>
string GenerateDocument();
}

View File

@@ -0,0 +1,62 @@
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Configuration options for OpenAPI document aggregation.
/// </summary>
public sealed class OpenApiAggregationOptions
{
/// <summary>
/// The configuration section name.
/// </summary>
public const string SectionName = "OpenApi";
/// <summary>
/// Gets or sets the API title.
/// </summary>
public string Title { get; set; } = "StellaOps Gateway API";
/// <summary>
/// Gets or sets the API description.
/// </summary>
public string Description { get; set; } = "Unified API aggregating all connected microservices.";
/// <summary>
/// Gets or sets the API version.
/// </summary>
public string Version { get; set; } = "1.0.0";
/// <summary>
/// Gets or sets the server URL.
/// </summary>
public string ServerUrl { get; set; } = "/";
/// <summary>
/// Gets or sets the cache TTL in seconds.
/// </summary>
public int CacheTtlSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets whether OpenAPI aggregation is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the license name.
/// </summary>
public string LicenseName { get; set; } = "AGPL-3.0-or-later";
/// <summary>
/// Gets or sets the contact name.
/// </summary>
public string? ContactName { get; set; }
/// <summary>
/// Gets or sets the contact email.
/// </summary>
public string? ContactEmail { get; set; }
/// <summary>
/// Gets or sets the OAuth2 token URL for security schemes.
/// </summary>
public string TokenUrl { get; set; } = "/auth/token";
}

View File

@@ -0,0 +1,285 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Generates OpenAPI 3.1.0 documents from aggregated microservice schemas.
/// </summary>
internal sealed class OpenApiDocumentGenerator : IOpenApiDocumentGenerator
{
private readonly IGlobalRoutingState _routingState;
private readonly OpenApiAggregationOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public OpenApiDocumentGenerator(
IGlobalRoutingState routingState,
IOptions<OpenApiAggregationOptions> options)
{
_routingState = routingState;
_options = options.Value;
}
/// <inheritdoc />
public string GenerateDocument()
{
var connections = _routingState.GetAllConnections();
var doc = new JsonObject
{
["openapi"] = "3.1.0",
["info"] = GenerateInfo(),
["servers"] = GenerateServers(),
["paths"] = GeneratePaths(connections),
["components"] = GenerateComponents(connections),
["tags"] = GenerateTags(connections)
};
return doc.ToJsonString(JsonOptions);
}
private JsonObject GenerateInfo()
{
var info = new JsonObject
{
["title"] = _options.Title,
["version"] = _options.Version,
["description"] = _options.Description,
["license"] = new JsonObject
{
["name"] = _options.LicenseName
}
};
if (_options.ContactName is not null || _options.ContactEmail is not null)
{
var contact = new JsonObject();
if (_options.ContactName is not null)
contact["name"] = _options.ContactName;
if (_options.ContactEmail is not null)
contact["email"] = _options.ContactEmail;
info["contact"] = contact;
}
return info;
}
private JsonArray GenerateServers()
{
return new JsonArray
{
new JsonObject
{
["url"] = _options.ServerUrl
}
};
}
private JsonObject GeneratePaths(IReadOnlyList<ConnectionState> connections)
{
var paths = new JsonObject();
// Group endpoints by path
var pathGroups = new Dictionary<string, List<(ConnectionState Conn, EndpointDescriptor Endpoint)>>();
foreach (var conn in connections)
{
foreach (var endpoint in conn.Endpoints.Values)
{
if (!pathGroups.TryGetValue(endpoint.Path, out var list))
{
list = [];
pathGroups[endpoint.Path] = list;
}
list.Add((conn, endpoint));
}
}
// Generate path items
foreach (var (path, endpoints) in pathGroups.OrderBy(p => p.Key))
{
var pathItem = new JsonObject();
foreach (var (conn, endpoint) in endpoints)
{
var operation = GenerateOperation(conn, endpoint);
var method = endpoint.Method.ToLowerInvariant();
pathItem[method] = operation;
}
paths[path] = pathItem;
}
return paths;
}
private JsonObject GenerateOperation(ConnectionState conn, EndpointDescriptor endpoint)
{
var operation = new JsonObject
{
["operationId"] = $"{conn.Instance.ServiceName}_{endpoint.Path.Replace("/", "_").Trim('_')}_{endpoint.Method}",
["tags"] = new JsonArray { conn.Instance.ServiceName }
};
// Add documentation from SchemaInfo
if (endpoint.SchemaInfo is not null)
{
if (endpoint.SchemaInfo.Summary is not null)
operation["summary"] = endpoint.SchemaInfo.Summary;
if (endpoint.SchemaInfo.Description is not null)
operation["description"] = endpoint.SchemaInfo.Description;
if (endpoint.SchemaInfo.Deprecated)
operation["deprecated"] = true;
// Override tags if specified
if (endpoint.SchemaInfo.Tags.Count > 0)
{
var tags = new JsonArray();
foreach (var tag in endpoint.SchemaInfo.Tags)
{
tags.Add(tag);
}
operation["tags"] = tags;
}
}
// Add security requirements
var security = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
if (security.Count > 0)
{
operation["security"] = security;
}
// Add request body if schema exists
if (endpoint.SchemaInfo?.RequestSchemaId is not null)
{
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.RequestSchemaId}";
operation["requestBody"] = new JsonObject
{
["required"] = true,
["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = new JsonObject
{
["$ref"] = schemaRef
}
}
}
};
}
// Add responses
var responses = new JsonObject();
// Success response
var successResponse = new JsonObject
{
["description"] = "Success"
};
if (endpoint.SchemaInfo?.ResponseSchemaId is not null)
{
var schemaRef = $"#/components/schemas/{conn.Instance.ServiceName}_{endpoint.SchemaInfo.ResponseSchemaId}";
successResponse["content"] = new JsonObject
{
["application/json"] = new JsonObject
{
["schema"] = new JsonObject
{
["$ref"] = schemaRef
}
}
};
}
responses["200"] = successResponse;
// Error responses
responses["400"] = new JsonObject { ["description"] = "Bad Request" };
responses["401"] = new JsonObject { ["description"] = "Unauthorized" };
responses["404"] = new JsonObject { ["description"] = "Not Found" };
responses["422"] = new JsonObject { ["description"] = "Validation Error" };
responses["500"] = new JsonObject { ["description"] = "Internal Server Error" };
operation["responses"] = responses;
return operation;
}
private JsonObject GenerateComponents(IReadOnlyList<ConnectionState> connections)
{
var components = new JsonObject();
// Generate schemas with service prefix
var schemas = new JsonObject();
foreach (var conn in connections)
{
foreach (var (schemaId, schemaDef) in conn.Schemas)
{
var prefixedId = $"{conn.Instance.ServiceName}_{schemaId}";
try
{
var schemaNode = JsonNode.Parse(schemaDef.SchemaJson);
if (schemaNode is not null)
{
schemas[prefixedId] = schemaNode;
}
}
catch (JsonException)
{
// Skip invalid schemas
}
}
}
if (schemas.Count > 0)
{
components["schemas"] = schemas;
}
// Generate security schemes
var allEndpoints = connections.SelectMany(c => c.Endpoints.Values);
var securitySchemes = ClaimSecurityMapper.GenerateSecuritySchemes(allEndpoints, _options.TokenUrl);
if (securitySchemes.Count > 0)
{
components["securitySchemes"] = securitySchemes;
}
return components;
}
private JsonArray GenerateTags(IReadOnlyList<ConnectionState> connections)
{
var tags = new JsonArray();
var seen = new HashSet<string>();
foreach (var conn in connections)
{
var serviceName = conn.Instance.ServiceName;
if (seen.Add(serviceName))
{
var tag = new JsonObject
{
["name"] = serviceName,
["description"] = $"{serviceName} microservice (v{conn.Instance.Version})"
};
if (conn.OpenApiInfo?.Description is not null)
{
tag["description"] = conn.OpenApiInfo.Description;
}
tags.Add(tag);
}
}
return tags;
}
}

View File

@@ -0,0 +1,124 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Gateway.WebService.OpenApi;
/// <summary>
/// Endpoints for serving OpenAPI documentation.
/// </summary>
public static class OpenApiEndpoints
{
private static readonly ISerializer YamlSerializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
/// <summary>
/// Maps OpenAPI endpoints to the application.
/// </summary>
public static IEndpointRouteBuilder MapGatewayOpenApiEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/.well-known/openapi", GetOpenApiDiscovery)
.ExcludeFromDescription();
endpoints.MapGet("/openapi.json", GetOpenApiJson)
.ExcludeFromDescription();
endpoints.MapGet("/openapi.yaml", GetOpenApiYaml)
.ExcludeFromDescription();
return endpoints;
}
private static IResult GetOpenApiDiscovery(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (_, etag, generatedAt) = cache.GetDocument();
var discovery = new
{
openapi_json = "/openapi.json",
openapi_yaml = "/openapi.yaml",
etag,
generated_at = generatedAt.ToString("O")
};
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Ok(discovery);
}
private static IResult GetOpenApiJson(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (documentJson, etag, _) = cache.GetDocument();
// Check If-None-Match header
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
{
if (ifNoneMatch == etag)
{
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.StatusCode(304);
}
}
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Content(documentJson, "application/json; charset=utf-8");
}
private static IResult GetOpenApiYaml(
[FromServices] IGatewayOpenApiDocumentCache cache,
HttpContext context)
{
var (documentJson, etag, _) = cache.GetDocument();
// Check If-None-Match header
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch))
{
if (ifNoneMatch == etag)
{
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.StatusCode(304);
}
}
// Convert JSON to YAML
var jsonNode = JsonNode.Parse(documentJson);
var yamlContent = ConvertToYaml(jsonNode);
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=60";
return Results.Content(yamlContent, "application/yaml; charset=utf-8");
}
private static string ConvertToYaml(JsonNode? node)
{
if (node is null)
return string.Empty;
var obj = ConvertJsonNodeToObject(node);
return YamlSerializer.Serialize(obj);
}
private static object? ConvertJsonNodeToObject(JsonNode? node)
{
return node switch
{
null => null,
JsonObject obj => obj.ToDictionary(
kvp => kvp.Key,
kvp => ConvertJsonNodeToObject(kvp.Value)),
JsonArray arr => arr.Select(ConvertJsonNodeToObject).ToList(),
JsonValue val => val.GetValue<object>(),
_ => null
};
}
}

View File

@@ -1,3 +1,4 @@
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Transport.InMemory;
@@ -41,6 +42,12 @@ public static class ServiceCollectionExtensions
// Register health monitor as hosted service
services.AddHostedService<HealthMonitorService>();
// Register OpenAPI aggregation services
services.Configure<OpenApiAggregationOptions>(
configuration.GetSection(OpenApiAggregationOptions.SectionName));
services.AddSingleton<IOpenApiDocumentGenerator, OpenApiDocumentGenerator>();
services.AddSingleton<IGatewayOpenApiDocumentCache, GatewayOpenApiDocumentCache>();
return services;
}

View File

@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />

View File

@@ -0,0 +1,270 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="AuthorityClaimsRefreshService"/>.
/// </summary>
public sealed class AuthorityClaimsRefreshServiceTests
{
private readonly Mock<IAuthorityClaimsProvider> _claimsProviderMock;
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
private readonly AuthorityConnectionOptions _options;
public AuthorityClaimsRefreshServiceTests()
{
_claimsProviderMock = new Mock<IAuthorityClaimsProvider>();
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
_options = new AuthorityConnectionOptions
{
AuthorityUrl = "http://authority.local",
Enabled = true,
RefreshInterval = TimeSpan.FromMilliseconds(100),
WaitForAuthorityOnStartup = false,
StartupTimeout = TimeSpan.FromSeconds(1)
};
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
}
private AuthorityClaimsRefreshService CreateService()
{
return new AuthorityClaimsRefreshService(
_claimsProviderMock.Object,
_claimsStoreMock.Object,
Options.Create(_options),
NullLogger<AuthorityClaimsRefreshService>.Instance);
}
#region ExecuteAsync Tests - Disabled
[Fact]
public async Task ExecuteAsync_WhenDisabled_DoesNotFetchClaims()
{
// Arrange
_options.Enabled = false;
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(50);
await service.StopAsync(cts.Token);
// Assert
_claimsProviderMock.Verify(
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WhenNoAuthorityUrl_DoesNotFetchClaims()
{
// Arrange
_options.AuthorityUrl = string.Empty;
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(50);
await service.StopAsync(cts.Token);
// Assert
_claimsProviderMock.Verify(
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
#region ExecuteAsync Tests - Enabled
[Fact]
public async Task ExecuteAsync_WhenEnabled_FetchesClaims()
{
// Arrange
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(50);
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert
_claimsProviderMock.Verify(
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_UpdatesStoreWithOverrides()
{
// Arrange
var key = EndpointKey.Create("service", "GET", "/api/test");
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
};
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(overrides);
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(50);
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert
_claimsStoreMock.Verify(
s => s.UpdateFromAuthority(It.Is<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>>(
d => d.ContainsKey(key))),
Times.AtLeastOnce);
}
#endregion
#region ExecuteAsync Tests - Wait for Authority
[Fact]
public async Task ExecuteAsync_WaitForAuthority_FetchesOnStartup()
{
// Arrange
_options.WaitForAuthorityOnStartup = true;
_options.StartupTimeout = TimeSpan.FromMilliseconds(500);
// Authority is immediately available
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(true);
var fetchCalled = false;
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
.Callback(() => fetchCalled = true)
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert - fetch was called during startup
fetchCalled.Should().BeTrue();
}
[Fact]
public async Task ExecuteAsync_WaitForAuthority_StopsAfterTimeout()
{
// Arrange
_options.WaitForAuthorityOnStartup = true;
_options.StartupTimeout = TimeSpan.FromMilliseconds(100);
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(false);
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act - should not block forever
var startTask = service.StartAsync(cts.Token);
await Task.Delay(300);
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert - should complete even if Authority never becomes available
startTask.IsCompleted.Should().BeTrue();
}
#endregion
#region Push Notification Tests
[Fact]
public async Task ExecuteAsync_WithPushNotifications_SubscribesToEvent()
{
// Arrange
_options.UseAuthorityPushNotifications = true;
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(50);
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert - verify event subscription by checking it doesn't throw
_claimsProviderMock.VerifyAdd(
p => p.OverridesChanged += It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
Times.Once);
}
[Fact]
public async Task Dispose_WithPushNotifications_UnsubscribesFromEvent()
{
// Arrange
_options.UseAuthorityPushNotifications = true;
var service = CreateService();
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
await Task.Delay(50);
// Act
await cts.CancelAsync();
service.Dispose();
// Assert
_claimsProviderMock.VerifyRemove(
p => p.OverridesChanged -= It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
Times.Once);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task ExecuteAsync_ProviderThrows_ContinuesRefreshLoop()
{
// Arrange
var callCount = 0;
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
throw new HttpRequestException("Test error");
}
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
});
var service = CreateService();
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(250); // Wait for at least 2 refresh cycles
await cts.CancelAsync();
await service.StopAsync(CancellationToken.None);
// Assert - should have continued after error
callCount.Should().BeGreaterThan(1);
}
#endregion
}

View File

@@ -0,0 +1,336 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="AuthorizationMiddleware"/>.
/// </summary>
public sealed class AuthorizationMiddlewareTests
{
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
private readonly Mock<RequestDelegate> _nextMock;
private bool _nextCalled;
public AuthorizationMiddlewareTests()
{
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
_nextMock = new Mock<RequestDelegate>();
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.Callback(() => _nextCalled = true)
.Returns(Task.CompletedTask);
}
private AuthorizationMiddleware CreateMiddleware()
{
return new AuthorizationMiddleware(
_nextMock.Object,
_claimsStoreMock.Object,
NullLogger<AuthorizationMiddleware>.Instance);
}
private static HttpContext CreateHttpContext(
EndpointDescriptor? endpoint = null,
ClaimsPrincipal? user = null)
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
if (endpoint is not null)
{
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
}
if (user is not null)
{
context.User = user;
}
return context;
}
private static EndpointDescriptor CreateEndpoint(
string serviceName = "test-service",
string method = "GET",
string path = "/api/test",
ClaimRequirement[]? claims = null)
{
return new EndpointDescriptor
{
ServiceName = serviceName,
Version = "1.0.0",
Method = method,
Path = path,
RequiringClaims = claims ?? []
};
}
private static ClaimsPrincipal CreateUserWithClaims(params (string Type, string Value)[] claims)
{
var identity = new ClaimsIdentity(
claims.Select(c => new Claim(c.Type, c.Value)),
"TestAuth");
return new ClaimsPrincipal(identity);
}
#region No Endpoint Tests
[Fact]
public async Task InvokeAsync_WithNoEndpoint_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(endpoint: null);
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region Empty Claims Tests
[Fact]
public async Task InvokeAsync_WithEmptyRequiringClaims_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var context = CreateHttpContext(endpoint: endpoint);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>());
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeTrue();
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
}
#endregion
#region Matching Claims Tests
[Fact]
public async Task InvokeAsync_WithMatchingClaims_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(("role", "admin"));
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeTrue();
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
}
[Fact]
public async Task InvokeAsync_WithClaimTypeOnly_MatchesAnyValue()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(("role", "any-value"));
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = null } // Any value matches
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeTrue();
}
[Fact]
public async Task InvokeAsync_WithMultipleMatchingClaims_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(
("role", "admin"),
("department", "engineering"),
("level", "senior"));
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" },
new() { Type = "department", Value = "engineering" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region Missing Claims Tests
[Fact]
public async Task InvokeAsync_WithMissingClaim_Returns403()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(("role", "user")); // Has role, but wrong value
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task InvokeAsync_WithMissingClaimType_Returns403()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(("department", "engineering"));
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task InvokeAsync_WithNoClaims_Returns403()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(); // No claims at all
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task InvokeAsync_WithPartialMatchingClaims_Returns403()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims(("role", "admin")); // Has one, missing another
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" },
new() { Type = "department", Value = "engineering" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
#endregion
#region Response Body Tests
[Fact]
public async Task InvokeAsync_WithMissingClaim_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var user = CreateUserWithClaims();
var context = CreateHttpContext(endpoint: endpoint, user: user);
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
endpoint.ServiceName, endpoint.Method, endpoint.Path))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await middleware.InvokeAsync(context);
// Assert
context.Response.ContentType.Should().StartWith("application/json");
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Forbidden");
responseBody.Should().Contain("role");
}
#endregion
}

View File

@@ -0,0 +1,404 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="EffectiveClaimsStore"/>.
/// </summary>
public sealed class EffectiveClaimsStoreTests
{
private readonly EffectiveClaimsStore _store;
public EffectiveClaimsStoreTests()
{
_store = new EffectiveClaimsStore(NullLogger<EffectiveClaimsStore>.Instance);
}
#region GetEffectiveClaims Tests
[Fact]
public void GetEffectiveClaims_NoClaimsRegistered_ReturnsEmptyList()
{
// Arrange - fresh store
// Act
var claims = _store.GetEffectiveClaims("service", "GET", "/api/test");
// Assert
claims.Should().BeEmpty();
}
[Fact]
public void GetEffectiveClaims_MicroserviceClaimsOnly_ReturnsMicroserviceClaims()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
// Assert
claims.Should().HaveCount(1);
claims[0].Type.Should().Be("role");
claims[0].Value.Should().Be("admin");
}
[Fact]
public void GetEffectiveClaims_AuthorityOverridesTakePrecedence()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints);
var key = EndpointKey.Create("test-service", "GET", "/api/users");
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
};
_store.UpdateFromAuthority(overrides);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
// Assert
claims.Should().HaveCount(1);
claims[0].Value.Should().Be("admin");
}
[Fact]
public void GetEffectiveClaims_MethodNormalization_MatchesCaseInsensitively()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "get",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
// Assert
claims.Should().HaveCount(1);
}
[Fact]
public void GetEffectiveClaims_PathNormalization_MatchesCaseInsensitively()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/API/USERS",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
// Assert
claims.Should().HaveCount(1);
}
#endregion
#region UpdateFromMicroservice Tests
[Fact]
public void UpdateFromMicroservice_MultipleEndpoints_RegistersAll()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "reader" }]
},
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "POST",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "writer" }]
}
};
// Act
_store.UpdateFromMicroservice("test-service", endpoints);
// Assert
_store.GetEffectiveClaims("test-service", "GET", "/api/users")[0].Value.Should().Be("reader");
_store.GetEffectiveClaims("test-service", "POST", "/api/users")[0].Value.Should().Be("writer");
}
[Fact]
public void UpdateFromMicroservice_EmptyClaims_RemovesFromStore()
{
// Arrange - first add some claims
var endpoints1 = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints1);
// Now update with empty claims
var endpoints2 = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = []
}
};
// Act
_store.UpdateFromMicroservice("test-service", endpoints2);
// Assert
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
}
[Fact]
public void UpdateFromMicroservice_DefaultEmptyClaims_TreatedAsEmpty()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users"
// RequiringClaims defaults to []
}
};
// Act
_store.UpdateFromMicroservice("test-service", endpoints);
// Assert
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
}
#endregion
#region UpdateFromAuthority Tests
[Fact]
public void UpdateFromAuthority_ClearsPreviousOverrides()
{
// Arrange - add initial override
var key1 = EndpointKey.Create("service1", "GET", "/api/test1");
var overrides1 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key1] = [new ClaimRequirement { Type = "role", Value = "old" }]
};
_store.UpdateFromAuthority(overrides1);
// Update with new overrides (different key)
var key2 = EndpointKey.Create("service2", "POST", "/api/test2");
var overrides2 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key2] = [new ClaimRequirement { Type = "role", Value = "new" }]
};
// Act
_store.UpdateFromAuthority(overrides2);
// Assert
_store.GetEffectiveClaims("service1", "GET", "/api/test1").Should().BeEmpty();
_store.GetEffectiveClaims("service2", "POST", "/api/test2").Should().HaveCount(1);
}
[Fact]
public void UpdateFromAuthority_EmptyClaimsNotStored()
{
// Arrange
var key = EndpointKey.Create("service", "GET", "/api/test");
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key] = []
};
// Act
_store.UpdateFromAuthority(overrides);
// Assert - should fall back to microservice (which is empty)
_store.GetEffectiveClaims("service", "GET", "/api/test").Should().BeEmpty();
}
[Fact]
public void UpdateFromAuthority_MultipleOverrides()
{
// Arrange
var key1 = EndpointKey.Create("service1", "GET", "/api/users");
var key2 = EndpointKey.Create("service1", "POST", "/api/users");
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[key1] = [new ClaimRequirement { Type = "role", Value = "reader" }],
[key2] = [new ClaimRequirement { Type = "role", Value = "writer" }]
};
// Act
_store.UpdateFromAuthority(overrides);
// Assert
_store.GetEffectiveClaims("service1", "GET", "/api/users")[0].Value.Should().Be("reader");
_store.GetEffectiveClaims("service1", "POST", "/api/users")[0].Value.Should().Be("writer");
}
#endregion
#region RemoveService Tests
[Fact]
public void RemoveService_RemovesMicroserviceClaims()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("test-service", endpoints);
// Act
_store.RemoveService("test-service");
// Assert
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
}
[Fact]
public void RemoveService_CaseInsensitive()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
ServiceName = "Test-Service",
Version = "1.0.0",
Method = "GET",
Path = "/api/users",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
}
};
_store.UpdateFromMicroservice("Test-Service", endpoints);
// Act - remove with different case
_store.RemoveService("TEST-SERVICE");
// Assert
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
}
[Fact]
public void RemoveService_OnlyRemovesTargetService()
{
// Arrange
var endpoints1 = new[]
{
new EndpointDescriptor
{
ServiceName = "service-a",
Version = "1.0.0",
Method = "GET",
Path = "/api/a",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "a" }]
}
};
var endpoints2 = new[]
{
new EndpointDescriptor
{
ServiceName = "service-b",
Version = "1.0.0",
Method = "GET",
Path = "/api/b",
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "b" }]
}
};
_store.UpdateFromMicroservice("service-a", endpoints1);
_store.UpdateFromMicroservice("service-b", endpoints2);
// Act
_store.RemoveService("service-a");
// Assert
_store.GetEffectiveClaims("service-a", "GET", "/api/a").Should().BeEmpty();
_store.GetEffectiveClaims("service-b", "GET", "/api/b").Should().HaveCount(1);
}
[Fact]
public void RemoveService_UnknownService_DoesNotThrow()
{
// Arrange & Act
var action = () => _store.RemoveService("unknown-service");
// Assert
action.Should().NotThrow();
}
#endregion
}

View File

@@ -0,0 +1,287 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Moq;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="EndpointResolutionMiddleware"/>.
/// </summary>
public sealed class EndpointResolutionMiddlewareTests
{
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly Mock<RequestDelegate> _nextMock;
private bool _nextCalled;
public EndpointResolutionMiddlewareTests()
{
_routingStateMock = new Mock<IGlobalRoutingState>();
_nextMock = new Mock<RequestDelegate>();
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.Callback(() => _nextCalled = true)
.Returns(Task.CompletedTask);
}
private EndpointResolutionMiddleware CreateMiddleware()
{
return new EndpointResolutionMiddleware(_nextMock.Object);
}
private static HttpContext CreateHttpContext(string method = "GET", string path = "/api/test")
{
var context = new DefaultHttpContext();
context.Request.Method = method;
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
private static EndpointDescriptor CreateEndpoint(
string serviceName = "test-service",
string method = "GET",
string path = "/api/test")
{
return new EndpointDescriptor
{
ServiceName = serviceName,
Version = "1.0.0",
Method = method,
Path = path
};
}
#region Matching Endpoint Tests
[Fact]
public async Task Invoke_WithMatchingEndpoint_SetsHttpContextItem()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var context = CreateHttpContext();
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
}
[Fact]
public async Task Invoke_WithMatchingEndpoint_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var context = CreateHttpContext();
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region Unknown Path Tests
[Fact]
public async Task Invoke_WithUnknownPath_Returns404()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(path: "/api/unknown");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
.Returns((EndpointDescriptor?)null);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
public async Task Invoke_WithUnknownPath_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(path: "/api/unknown");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
.Returns((EndpointDescriptor?)null);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("not found");
responseBody.Should().Contain("/api/unknown");
}
#endregion
#region HTTP Method Tests
[Fact]
public async Task Invoke_WithPostMethod_ResolvesCorrectly()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(method: "POST");
var context = CreateHttpContext(method: "POST");
_routingStateMock.Setup(r => r.ResolveEndpoint("POST", "/api/test"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
}
[Fact]
public async Task Invoke_WithDeleteMethod_ResolvesCorrectly()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(method: "DELETE", path: "/api/users/123");
var context = CreateHttpContext(method: "DELETE", path: "/api/users/123");
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/users/123"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
[Fact]
public async Task Invoke_WithWrongMethod_Returns404()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(method: "DELETE", path: "/api/test");
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/test"))
.Returns((EndpointDescriptor?)null);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
#endregion
#region Path Variations Tests
[Fact]
public async Task Invoke_WithParameterizedPath_ResolvesCorrectly()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(path: "/api/users/{id}");
var context = CreateHttpContext(path: "/api/users/123");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users/123"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
}
[Fact]
public async Task Invoke_WithRootPath_ResolvesCorrectly()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(path: "/");
var context = CreateHttpContext(path: "/");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/"))
.Returns(endpoint);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
[Fact]
public async Task Invoke_WithEmptyPath_PassesEmptyStringToRouting()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(path: "");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", ""))
.Returns((EndpointDescriptor?)null);
// Act
await middleware.Invoke(context, _routingStateMock.Object);
// Assert
_routingStateMock.Verify(r => r.ResolveEndpoint("GET", ""), Times.Once);
}
#endregion
#region Multiple Calls Tests
[Fact]
public async Task Invoke_MultipleCalls_EachResolvesIndependently()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint1 = CreateEndpoint(path: "/api/users");
var endpoint2 = CreateEndpoint(path: "/api/items");
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users"))
.Returns(endpoint1);
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/items"))
.Returns(endpoint2);
var context1 = CreateHttpContext(path: "/api/users");
var context2 = CreateHttpContext(path: "/api/items");
// Act
await middleware.Invoke(context1, _routingStateMock.Object);
await middleware.Invoke(context2, _routingStateMock.Object);
// Assert
context1.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint1);
context2.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint2);
}
#endregion
}

View File

@@ -0,0 +1,356 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="HttpAuthorityClaimsProvider"/>.
/// </summary>
public sealed class HttpAuthorityClaimsProviderTests
{
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly HttpClient _httpClient;
private readonly AuthorityConnectionOptions _options;
public HttpAuthorityClaimsProviderTests()
{
_httpHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpHandlerMock.Object);
_options = new AuthorityConnectionOptions
{
AuthorityUrl = "http://authority.local"
};
}
private HttpAuthorityClaimsProvider CreateProvider()
{
return new HttpAuthorityClaimsProvider(
_httpClient,
Options.Create(_options),
NullLogger<HttpAuthorityClaimsProvider>.Instance);
}
#region GetOverridesAsync Tests
[Fact]
public async Task GetOverridesAsync_NoAuthorityUrl_ReturnsEmpty()
{
// Arrange
_options.AuthorityUrl = string.Empty;
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task GetOverridesAsync_WhitespaceUrl_ReturnsEmpty()
{
// Arrange
_options.AuthorityUrl = " ";
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task GetOverridesAsync_SuccessfulResponse_ParsesOverrides()
{
// Arrange
var responseBody = JsonSerializer.Serialize(new
{
overrides = new[]
{
new
{
serviceName = "test-service",
method = "GET",
path = "/api/users",
requiringClaims = new[]
{
new { type = "role", value = "admin" }
}
}
}
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
SetupHttpResponse(HttpStatusCode.OK, responseBody);
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().HaveCount(1);
provider.IsAvailable.Should().BeTrue();
var key = result.Keys.First();
key.ServiceName.Should().Be("test-service");
key.Method.Should().Be("GET");
key.Path.Should().Be("/api/users");
result[key].Should().HaveCount(1);
result[key][0].Type.Should().Be("role");
result[key][0].Value.Should().Be("admin");
}
[Fact]
public async Task GetOverridesAsync_EmptyOverrides_ReturnsEmpty()
{
// Arrange
var responseBody = JsonSerializer.Serialize(new
{
overrides = Array.Empty<object>()
});
SetupHttpResponse(HttpStatusCode.OK, responseBody);
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeTrue();
}
[Fact]
public async Task GetOverridesAsync_NullOverrides_ReturnsEmpty()
{
// Arrange
var responseBody = "{}";
SetupHttpResponse(HttpStatusCode.OK, responseBody);
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeTrue();
}
[Fact]
public async Task GetOverridesAsync_HttpError_ReturnsEmptyAndSetsUnavailable()
{
// Arrange
SetupHttpResponse(HttpStatusCode.InternalServerError, "Error");
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task GetOverridesAsync_Timeout_ReturnsEmptyAndSetsUnavailable()
{
// Arrange
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new TaskCanceledException("Timeout"));
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task GetOverridesAsync_NetworkError_ReturnsEmptyAndSetsUnavailable()
{
// Arrange
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().BeEmpty();
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task GetOverridesAsync_TrimsTrailingSlash()
{
// Arrange
_options.AuthorityUrl = "http://authority.local/";
var responseBody = JsonSerializer.Serialize(new { overrides = Array.Empty<object>() });
string? capturedUrl = null;
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
capturedUrl = req.RequestUri?.ToString();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody)
};
});
var provider = CreateProvider();
// Act
await provider.GetOverridesAsync(CancellationToken.None);
// Assert
capturedUrl.Should().Be("http://authority.local/api/v1/claims/overrides");
}
[Fact]
public async Task GetOverridesAsync_MultipleOverrides_ParsesAll()
{
// Arrange
var responseBody = JsonSerializer.Serialize(new
{
overrides = new[]
{
new
{
serviceName = "service-a",
method = "GET",
path = "/api/a",
requiringClaims = new[] { new { type = "role", value = "a" } }
},
new
{
serviceName = "service-b",
method = "POST",
path = "/api/b",
requiringClaims = new[]
{
new { type = "role", value = "b1" },
new { type = "department", value = "b2" }
}
}
}
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
SetupHttpResponse(HttpStatusCode.OK, responseBody);
var provider = CreateProvider();
// Act
var result = await provider.GetOverridesAsync(CancellationToken.None);
// Assert
result.Should().HaveCount(2);
}
#endregion
#region IsAvailable Tests
[Fact]
public void IsAvailable_InitiallyFalse()
{
// Arrange
var provider = CreateProvider();
// Assert
provider.IsAvailable.Should().BeFalse();
}
[Fact]
public async Task IsAvailable_TrueAfterSuccessfulFetch()
{
// Arrange
SetupHttpResponse(HttpStatusCode.OK, "{}");
var provider = CreateProvider();
// Act
await provider.GetOverridesAsync(CancellationToken.None);
// Assert
provider.IsAvailable.Should().BeTrue();
}
[Fact]
public async Task IsAvailable_FalseAfterFailedFetch()
{
// Arrange
SetupHttpResponse(HttpStatusCode.ServiceUnavailable, "");
var provider = CreateProvider();
// Act
await provider.GetOverridesAsync(CancellationToken.None);
// Assert
provider.IsAvailable.Should().BeFalse();
}
#endregion
#region OverridesChanged Event Tests
[Fact]
public void OverridesChanged_CanBeSubscribed()
{
// Arrange
var provider = CreateProvider();
var eventRaised = false;
// Act
provider.OverridesChanged += (_, _) => eventRaised = true;
// Assert - no exception during subscription, event not raised yet
eventRaised.Should().BeFalse();
provider.Should().NotBeNull();
}
#endregion
#region Helper Methods
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
});
}
#endregion
}

View File

@@ -0,0 +1,182 @@
using FluentAssertions;
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
public class ClaimSecurityMapperTests
{
[Fact]
public void GenerateSecuritySchemes_WithNoEndpoints_ReturnsBearerAuthOnly()
{
// Arrange
var endpoints = Array.Empty<EndpointDescriptor>();
// Act
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
// Assert
schemes.Should().ContainKey("BearerAuth");
schemes.Should().NotContainKey("OAuth2");
}
[Fact]
public void GenerateSecuritySchemes_WithClaimRequirements_ReturnsOAuth2()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
Method = "POST",
Path = "/test",
ServiceName = "test",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
}
};
// Act
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
// Assert
schemes.Should().ContainKey("BearerAuth");
schemes.Should().ContainKey("OAuth2");
}
[Fact]
public void GenerateSecuritySchemes_CollectsAllUniqueScopes()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
Method = "POST",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
},
new EndpointDescriptor
{
Method = "GET",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "billing:read" }]
},
new EndpointDescriptor
{
Method = "POST",
Path = "/payments",
ServiceName = "billing",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }] // Duplicate
}
};
// Act
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
// Assert
var oauth2 = schemes["OAuth2"];
var scopes = oauth2!["flows"]!["clientCredentials"]!["scopes"]!;
scopes.AsObject().Count.Should().Be(2); // Only unique scopes
scopes["billing:write"].Should().NotBeNull();
scopes["billing:read"].Should().NotBeNull();
}
[Fact]
public void GenerateSecuritySchemes_SetsCorrectTokenUrl()
{
// Arrange
var endpoints = new[]
{
new EndpointDescriptor
{
Method = "POST",
Path = "/test",
ServiceName = "test",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
}
};
// Act
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/custom/token");
// Assert
var tokenUrl = schemes["OAuth2"]!["flows"]!["clientCredentials"]!["tokenUrl"]!.GetValue<string>();
tokenUrl.Should().Be("/custom/token");
}
[Fact]
public void GenerateSecurityRequirement_WithNoClaimRequirements_ReturnsEmptyArray()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "GET",
Path = "/public",
ServiceName = "test",
Version = "1.0.0",
RequiringClaims = []
};
// Act
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
// Assert
requirement.Count.Should().Be(0);
}
[Fact]
public void GenerateSecurityRequirement_WithClaimRequirements_ReturnsBearerAndOAuth2()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "POST",
Path = "/secure",
ServiceName = "test",
Version = "1.0.0",
RequiringClaims =
[
new ClaimRequirement { Type = "billing:write" },
new ClaimRequirement { Type = "billing:admin" }
]
};
// Act
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
// Assert
requirement.Count.Should().Be(1);
var req = requirement[0]!.AsObject();
req.Should().ContainKey("BearerAuth");
req.Should().ContainKey("OAuth2");
var scopes = req["OAuth2"]!.AsArray();
scopes.Count.Should().Be(2);
}
[Fact]
public void GenerateSecuritySchemes_BearerAuth_HasCorrectStructure()
{
// Arrange
var endpoints = Array.Empty<EndpointDescriptor>();
// Act
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
// Assert
var bearer = schemes["BearerAuth"]!.AsObject();
bearer["type"]!.GetValue<string>().Should().Be("http");
bearer["scheme"]!.GetValue<string>().Should().Be("bearer");
bearer["bearerFormat"]!.GetValue<string>().Should().Be("JWT");
}
}

View File

@@ -0,0 +1,166 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Gateway.WebService.OpenApi;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
public class GatewayOpenApiDocumentCacheTests
{
private readonly Mock<IOpenApiDocumentGenerator> _generator = new();
private readonly OpenApiAggregationOptions _options = new() { CacheTtlSeconds = 60 };
private readonly GatewayOpenApiDocumentCache _sut;
public GatewayOpenApiDocumentCacheTests()
{
_sut = new GatewayOpenApiDocumentCache(
_generator.Object,
Options.Create(_options));
}
[Fact]
public void GetDocument_FirstCall_GeneratesDocument()
{
// Arrange
var expectedDoc = """{"openapi":"3.1.0"}""";
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
// Act
var (doc, _, _) = _sut.GetDocument();
// Assert
doc.Should().Be(expectedDoc);
_generator.Verify(x => x.GenerateDocument(), Times.Once);
}
[Fact]
public void GetDocument_SubsequentCalls_ReturnsCachedDocument()
{
// Arrange
var expectedDoc = """{"openapi":"3.1.0"}""";
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
// Act
var (doc1, _, _) = _sut.GetDocument();
var (doc2, _, _) = _sut.GetDocument();
var (doc3, _, _) = _sut.GetDocument();
// Assert
doc1.Should().Be(expectedDoc);
doc2.Should().Be(expectedDoc);
doc3.Should().Be(expectedDoc);
_generator.Verify(x => x.GenerateDocument(), Times.Once);
}
[Fact]
public void GetDocument_AfterInvalidate_RegeneratesDocument()
{
// Arrange
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
_generator.SetupSequence(x => x.GenerateDocument())
.Returns(doc1)
.Returns(doc2);
// Act
var (result1, _, _) = _sut.GetDocument();
_sut.Invalidate();
var (result2, _, _) = _sut.GetDocument();
// Assert
result1.Should().Be(doc1);
result2.Should().Be(doc2);
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
}
[Fact]
public void GetDocument_ReturnsConsistentETag()
{
// Arrange
var expectedDoc = """{"openapi":"3.1.0"}""";
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
// Act
var (_, etag1, _) = _sut.GetDocument();
var (_, etag2, _) = _sut.GetDocument();
// Assert
etag1.Should().NotBeNullOrEmpty();
etag1.Should().Be(etag2);
etag1.Should().StartWith("\"").And.EndWith("\""); // ETag format
}
[Fact]
public void GetDocument_DifferentContent_DifferentETag()
{
// Arrange
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
_generator.SetupSequence(x => x.GenerateDocument())
.Returns(doc1)
.Returns(doc2);
// Act
var (_, etag1, _) = _sut.GetDocument();
_sut.Invalidate();
var (_, etag2, _) = _sut.GetDocument();
// Assert
etag1.Should().NotBe(etag2);
}
[Fact]
public void GetDocument_ReturnsGenerationTimestamp()
{
// Arrange
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
var beforeGeneration = DateTime.UtcNow;
// Act
var (_, _, generatedAt) = _sut.GetDocument();
// Assert
generatedAt.Should().BeOnOrAfter(beforeGeneration);
generatedAt.Should().BeOnOrBefore(DateTime.UtcNow);
}
[Fact]
public void Invalidate_CanBeCalledMultipleTimes()
{
// Arrange
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
_sut.GetDocument();
// Act & Assert - should not throw
_sut.Invalidate();
_sut.Invalidate();
_sut.Invalidate();
}
[Fact]
public void GetDocument_WithZeroTtl_AlwaysRegenerates()
{
// Arrange
var options = new OpenApiAggregationOptions { CacheTtlSeconds = 0 };
var sut = new GatewayOpenApiDocumentCache(
_generator.Object,
Options.Create(options));
var callCount = 0;
_generator.Setup(x => x.GenerateDocument())
.Returns(() => $"{{\"call\":{++callCount}}}");
// Act
sut.GetDocument();
// Wait a tiny bit to ensure TTL is exceeded
Thread.Sleep(10);
sut.GetDocument();
// Assert
// With 0 TTL, each call should regenerate
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
}
}

View File

@@ -0,0 +1,338 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Gateway.WebService.OpenApi;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
public class OpenApiDocumentGeneratorTests
{
private readonly Mock<IGlobalRoutingState> _routingState = new();
private readonly OpenApiAggregationOptions _options = new();
private readonly OpenApiDocumentGenerator _sut;
public OpenApiDocumentGeneratorTests()
{
_sut = new OpenApiDocumentGenerator(
_routingState.Object,
Options.Create(_options));
}
private static ConnectionState CreateConnection(
string serviceName = "test-service",
string version = "1.0.0",
params EndpointDescriptor[] endpoints)
{
var connection = new ConnectionState
{
ConnectionId = $"conn-{serviceName}",
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{serviceName}",
ServiceName = serviceName,
Version = version,
Region = "us-east-1"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.InMemory,
Schemas = new Dictionary<string, SchemaDefinition>(),
OpenApiInfo = new ServiceOpenApiInfo
{
Title = serviceName,
Description = $"Test {serviceName} service"
}
};
foreach (var endpoint in endpoints)
{
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
}
return connection;
}
[Fact]
public void GenerateDocument_WithNoConnections_ReturnsValidOpenApiDocument()
{
// Arrange
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
// Act
var document = _sut.GenerateDocument();
// Assert
document.Should().NotBeNullOrEmpty();
var doc = JsonDocument.Parse(document);
doc.RootElement.GetProperty("openapi").GetString().Should().Be("3.1.0");
doc.RootElement.GetProperty("info").GetProperty("title").GetString().Should().Be(_options.Title);
}
[Fact]
public void GenerateDocument_SetsCorrectInfoSection()
{
// Arrange
_options.Title = "My Gateway API";
_options.Description = "My description";
_options.Version = "2.0.0";
_options.LicenseName = "MIT";
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
var info = doc.RootElement.GetProperty("info");
info.GetProperty("title").GetString().Should().Be("My Gateway API");
info.GetProperty("description").GetString().Should().Be("My description");
info.GetProperty("version").GetString().Should().Be("2.0.0");
info.GetProperty("license").GetProperty("name").GetString().Should().Be("MIT");
}
[Fact]
public void GenerateDocument_WithConnections_GeneratesPaths()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "GET",
Path = "/api/items",
ServiceName = "inventory",
Version = "1.0.0"
};
var connection = CreateConnection("inventory", "1.0.0", endpoint);
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
var paths = doc.RootElement.GetProperty("paths");
paths.TryGetProperty("/api/items", out var pathItem).Should().BeTrue();
pathItem.TryGetProperty("get", out var operation).Should().BeTrue();
}
[Fact]
public void GenerateDocument_WithSchemaInfo_IncludesDocumentation()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "POST",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0",
SchemaInfo = new EndpointSchemaInfo
{
Summary = "Create invoice",
Description = "Creates a new invoice",
Tags = ["billing", "invoices"],
Deprecated = false
}
};
var connection = CreateConnection("billing", "1.0.0", endpoint);
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
var operation = doc.RootElement
.GetProperty("paths")
.GetProperty("/invoices")
.GetProperty("post");
operation.GetProperty("summary").GetString().Should().Be("Create invoice");
operation.GetProperty("description").GetString().Should().Be("Creates a new invoice");
}
[Fact]
public void GenerateDocument_WithSchemas_IncludesSchemaReferences()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "POST",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0",
SchemaInfo = new EndpointSchemaInfo
{
RequestSchemaId = "CreateInvoiceRequest"
}
};
var connection = CreateConnection("billing", "1.0.0", endpoint);
var connectionWithSchemas = new ConnectionState
{
ConnectionId = connection.ConnectionId,
Instance = connection.Instance,
Status = connection.Status,
TransportType = connection.TransportType,
Schemas = new Dictionary<string, SchemaDefinition>
{
["CreateInvoiceRequest"] = new SchemaDefinition
{
SchemaId = "CreateInvoiceRequest",
SchemaJson = """{"type": "object", "properties": {"amount": {"type": "number"}}}""",
ETag = "\"ABC123\""
}
}
};
connectionWithSchemas.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
_routingState.Setup(x => x.GetAllConnections()).Returns([connectionWithSchemas]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
// Check request body reference
var requestBody = doc.RootElement
.GetProperty("paths")
.GetProperty("/invoices")
.GetProperty("post")
.GetProperty("requestBody")
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema")
.GetProperty("$ref")
.GetString();
requestBody.Should().Be("#/components/schemas/billing_CreateInvoiceRequest");
// Check schema exists in components
var schemas = doc.RootElement.GetProperty("components").GetProperty("schemas");
schemas.TryGetProperty("billing_CreateInvoiceRequest", out _).Should().BeTrue();
}
[Fact]
public void GenerateDocument_WithClaimRequirements_IncludesSecurity()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "POST",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0",
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
};
var connection = CreateConnection("billing", "1.0.0", endpoint);
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
// Check security schemes
var securitySchemes = doc.RootElement
.GetProperty("components")
.GetProperty("securitySchemes");
securitySchemes.TryGetProperty("BearerAuth", out _).Should().BeTrue();
securitySchemes.TryGetProperty("OAuth2", out _).Should().BeTrue();
// Check operation security
var operation = doc.RootElement
.GetProperty("paths")
.GetProperty("/invoices")
.GetProperty("post");
operation.TryGetProperty("security", out _).Should().BeTrue();
}
[Fact]
public void GenerateDocument_WithMultipleServices_GeneratesTags()
{
// Arrange
var billingEndpoint = new EndpointDescriptor
{
Method = "POST",
Path = "/invoices",
ServiceName = "billing",
Version = "1.0.0"
};
var inventoryEndpoint = new EndpointDescriptor
{
Method = "GET",
Path = "/items",
ServiceName = "inventory",
Version = "2.0.0"
};
var billingConn = CreateConnection("billing", "1.0.0", billingEndpoint);
var inventoryConn = CreateConnection("inventory", "2.0.0", inventoryEndpoint);
_routingState.Setup(x => x.GetAllConnections()).Returns([billingConn, inventoryConn]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
var tags = doc.RootElement.GetProperty("tags");
tags.GetArrayLength().Should().Be(2);
var tagNames = new List<string>();
foreach (var tag in tags.EnumerateArray())
{
tagNames.Add(tag.GetProperty("name").GetString()!);
}
tagNames.Should().Contain("billing");
tagNames.Should().Contain("inventory");
}
[Fact]
public void GenerateDocument_WithDeprecatedEndpoint_SetsDeprecatedFlag()
{
// Arrange
var endpoint = new EndpointDescriptor
{
Method = "GET",
Path = "/legacy",
ServiceName = "test",
Version = "1.0.0",
SchemaInfo = new EndpointSchemaInfo
{
Deprecated = true
}
};
var connection = CreateConnection("test", "1.0.0", endpoint);
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
// Act
var document = _sut.GenerateDocument();
// Assert
var doc = JsonDocument.Parse(document);
var operation = doc.RootElement
.GetProperty("paths")
.GetProperty("/legacy")
.GetProperty("get");
operation.GetProperty("deprecated").GetBoolean().Should().BeTrue();
}
}

View File

@@ -0,0 +1,337 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="PayloadLimitsMiddleware"/>.
/// </summary>
public sealed class PayloadLimitsMiddlewareTests
{
private readonly Mock<IPayloadTracker> _trackerMock;
private readonly Mock<RequestDelegate> _nextMock;
private readonly PayloadLimits _defaultLimits;
private bool _nextCalled;
public PayloadLimitsMiddlewareTests()
{
_trackerMock = new Mock<IPayloadTracker>();
_nextMock = new Mock<RequestDelegate>();
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.Callback(() => _nextCalled = true)
.Returns(Task.CompletedTask);
_defaultLimits = new PayloadLimits
{
MaxRequestBytesPerCall = 10 * 1024 * 1024, // 10MB
MaxRequestBytesPerConnection = 100 * 1024 * 1024, // 100MB
MaxAggregateInflightBytes = 1024 * 1024 * 1024 // 1GB
};
}
private PayloadLimitsMiddleware CreateMiddleware(PayloadLimits? limits = null)
{
return new PayloadLimitsMiddleware(
_nextMock.Object,
Options.Create(limits ?? _defaultLimits),
NullLogger<PayloadLimitsMiddleware>.Instance);
}
private static HttpContext CreateHttpContext(long? contentLength = null, string connectionId = "conn-1")
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Body = new MemoryStream();
context.Connection.Id = connectionId;
if (contentLength.HasValue)
{
context.Request.ContentLength = contentLength;
}
return context;
}
#region Within Limits Tests
[Fact]
public async Task Invoke_WithinLimits_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
[Fact]
public async Task Invoke_WithNoContentLength_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: null);
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
[Fact]
public async Task Invoke_WithZeroContentLength_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 0);
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region Per-Call Limit Tests
[Fact]
public async Task Invoke_ExceedsPerCallLimit_Returns413()
{
// Arrange
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
var middleware = CreateMiddleware(limits);
var context = CreateHttpContext(contentLength: 2000);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge);
}
[Fact]
public async Task Invoke_ExceedsPerCallLimit_WritesErrorResponse()
{
// Arrange
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
var middleware = CreateMiddleware(limits);
var context = CreateHttpContext(contentLength: 2000);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Payload Too Large");
responseBody.Should().Contain("1000");
responseBody.Should().Contain("2000");
}
[Fact]
public async Task Invoke_ExactlyAtPerCallLimit_CallsNext()
{
// Arrange
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
var middleware = CreateMiddleware(limits);
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region Aggregate Limit Tests
[Fact]
public async Task Invoke_ExceedsAggregateLimit_Returns503()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(false);
_trackerMock.Setup(t => t.IsOverloaded)
.Returns(true);
_trackerMock.Setup(t => t.CurrentInflightBytes)
.Returns(1024 * 1024 * 1024); // 1GB
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task Invoke_ExceedsAggregateLimit_WritesOverloadedResponse()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(false);
_trackerMock.Setup(t => t.IsOverloaded)
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Overloaded");
}
#endregion
#region Per-Connection Limit Tests
[Fact]
public async Task Invoke_ExceedsPerConnectionLimit_Returns429()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(false);
_trackerMock.Setup(t => t.IsOverloaded)
.Returns(false); // Not aggregate limit
_trackerMock.Setup(t => t.GetConnectionInflightBytes("conn-1"))
.Returns(100 * 1024 * 1024); // 100MB
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
}
[Fact]
public async Task Invoke_ExceedsPerConnectionLimit_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(false);
_trackerMock.Setup(t => t.IsOverloaded)
.Returns(false);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Too Many Requests");
}
#endregion
#region Release Tests
[Fact]
public async Task Invoke_AfterSuccess_ReleasesReservation()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(true);
// Act
await middleware.Invoke(context, _trackerMock.Object);
// Assert
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
}
[Fact]
public async Task Invoke_AfterNextThrows_StillReleasesReservation()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(contentLength: 1000);
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
.Returns(true);
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.ThrowsAsync(new InvalidOperationException("Test error"));
// Act
var act = async () => await middleware.Invoke(context, _trackerMock.Object);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>();
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
}
#endregion
#region Different Connections Tests
[Fact]
public async Task Invoke_DifferentConnections_TrackedSeparately()
{
// Arrange
var middleware = CreateMiddleware();
var context1 = CreateHttpContext(contentLength: 1000, connectionId: "conn-1");
var context2 = CreateHttpContext(contentLength: 2000, connectionId: "conn-2");
_trackerMock.Setup(t => t.TryReserve(It.IsAny<string>(), It.IsAny<long>()))
.Returns(true);
// Act
await middleware.Invoke(context1, _trackerMock.Object);
await middleware.Invoke(context2, _trackerMock.Object);
// Assert
_trackerMock.Verify(t => t.TryReserve("conn-1", 1000), Times.Once);
_trackerMock.Verify(t => t.TryReserve("conn-2", 2000), Times.Once);
}
#endregion
}

View File

@@ -0,0 +1,429 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="RoutingDecisionMiddleware"/>.
/// </summary>
public sealed class RoutingDecisionMiddlewareTests
{
private readonly Mock<IRoutingPlugin> _routingPluginMock;
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly Mock<RequestDelegate> _nextMock;
private readonly GatewayNodeConfig _gatewayConfig;
private readonly RoutingOptions _routingOptions;
private bool _nextCalled;
public RoutingDecisionMiddlewareTests()
{
_routingPluginMock = new Mock<IRoutingPlugin>();
_routingStateMock = new Mock<IGlobalRoutingState>();
_nextMock = new Mock<RequestDelegate>();
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.Callback(() => _nextCalled = true)
.Returns(Task.CompletedTask);
_gatewayConfig = new GatewayNodeConfig
{
Region = "us-east-1",
NodeId = "gw-01",
Environment = "test"
};
_routingOptions = new RoutingOptions
{
DefaultVersion = "1.0.0"
};
}
private RoutingDecisionMiddleware CreateMiddleware()
{
return new RoutingDecisionMiddleware(_nextMock.Object);
}
private HttpContext CreateHttpContext(EndpointDescriptor? endpoint = null)
{
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Path = "/api/test";
context.Response.Body = new MemoryStream();
if (endpoint is not null)
{
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
}
return context;
}
private static EndpointDescriptor CreateEndpoint(
string serviceName = "test-service",
string version = "1.0.0")
{
return new EndpointDescriptor
{
ServiceName = serviceName,
Version = version,
Method = "GET",
Path = "/api/test"
};
}
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
},
Status = status,
TransportType = TransportType.InMemory
};
}
private static RoutingDecision CreateDecision(
EndpointDescriptor? endpoint = null,
ConnectionState? connection = null)
{
return new RoutingDecision
{
Endpoint = endpoint ?? CreateEndpoint(),
Connection = connection ?? CreateConnection(),
TransportType = TransportType.InMemory,
EffectiveTimeout = TimeSpan.FromSeconds(30)
};
}
#region Missing Endpoint Tests
[Fact]
public async Task Invoke_WithNoEndpoint_Returns500()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(endpoint: null);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
}
[Fact]
public async Task Invoke_WithNoEndpoint_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(endpoint: null);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("descriptor missing");
}
#endregion
#region Available Instance Tests
[Fact]
public async Task Invoke_WithAvailableInstance_SetsRoutingDecision()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var connection = CreateConnection();
var decision = CreateDecision(endpoint, connection);
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
.Returns([connection]);
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
_nextCalled.Should().BeTrue();
context.Items[RouterHttpContextKeys.RoutingDecision].Should().Be(decision);
}
[Fact]
public async Task Invoke_WithAvailableInstance_CallsNext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var decision = CreateDecision(endpoint);
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([CreateConnection()]);
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
_nextCalled.Should().BeTrue();
}
#endregion
#region No Instances Tests
[Fact]
public async Task Invoke_WithNoInstances_Returns503()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([]);
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RoutingDecision?)null);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task Invoke_WithNoInstances_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([]);
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RoutingDecision?)null);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("No instances available");
responseBody.Should().Contain("test-service");
}
#endregion
#region Routing Context Tests
[Fact]
public async Task Invoke_PassesCorrectRoutingContext()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var decision = CreateDecision(endpoint);
var connection = CreateConnection();
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
.Returns([connection]);
RoutingContext? capturedContext = null;
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
capturedContext.Should().NotBeNull();
capturedContext!.Method.Should().Be("GET");
capturedContext.Path.Should().Be("/api/test");
capturedContext.GatewayRegion.Should().Be("us-east-1");
capturedContext.Endpoint.Should().Be(endpoint);
capturedContext.AvailableConnections.Should().ContainSingle();
}
[Fact]
public async Task Invoke_PassesRequestHeaders()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var decision = CreateDecision(endpoint);
var context = CreateHttpContext(endpoint: endpoint);
context.Request.Headers["X-Custom-Header"] = "CustomValue";
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([CreateConnection()]);
RoutingContext? capturedContext = null;
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
capturedContext!.Headers.Should().ContainKey("X-Custom-Header");
capturedContext.Headers["X-Custom-Header"].Should().Be("CustomValue");
}
#endregion
#region Version Extraction Tests
[Fact]
public async Task Invoke_WithXApiVersionHeader_ExtractsVersion()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var decision = CreateDecision(endpoint);
var context = CreateHttpContext(endpoint: endpoint);
context.Request.Headers["X-Api-Version"] = "2.0.0";
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([CreateConnection()]);
RoutingContext? capturedContext = null;
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
capturedContext!.RequestedVersion.Should().Be("2.0.0");
}
[Fact]
public async Task Invoke_WithNoVersionHeader_UsesDefault()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint();
var decision = CreateDecision(endpoint);
var context = CreateHttpContext(endpoint: endpoint);
_routingStateMock.Setup(r => r.GetConnectionsFor(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns([CreateConnection()]);
RoutingContext? capturedContext = null;
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
.ReturnsAsync(decision);
// Act
await middleware.Invoke(
context,
_routingPluginMock.Object,
_routingStateMock.Object,
Options.Create(_gatewayConfig),
Options.Create(_routingOptions));
// Assert
capturedContext!.RequestedVersion.Should().Be("1.0.0"); // From _routingOptions
}
#endregion
}

View File

@@ -11,6 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,786 @@
using System.Text;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests;
/// <summary>
/// Unit tests for <see cref="TransportDispatchMiddleware"/>.
/// </summary>
public sealed class TransportDispatchMiddlewareTests
{
private readonly Mock<ITransportClient> _transportClientMock;
private readonly Mock<IGlobalRoutingState> _routingStateMock;
private readonly Mock<RequestDelegate> _nextMock;
private bool _nextCalled;
public TransportDispatchMiddlewareTests()
{
_transportClientMock = new Mock<ITransportClient>();
_routingStateMock = new Mock<IGlobalRoutingState>();
_nextMock = new Mock<RequestDelegate>();
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
.Callback(() => _nextCalled = true)
.Returns(Task.CompletedTask);
}
private TransportDispatchMiddleware CreateMiddleware()
{
return new TransportDispatchMiddleware(
_nextMock.Object,
NullLogger<TransportDispatchMiddleware>.Instance);
}
private static HttpContext CreateHttpContext(
RoutingDecision? decision = null,
string method = "GET",
string path = "/api/test",
byte[]? body = null)
{
var context = new DefaultHttpContext();
context.Request.Method = method;
context.Request.Path = path;
context.Response.Body = new MemoryStream();
if (body is not null)
{
context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length;
}
else
{
context.Request.Body = new MemoryStream();
}
if (decision is not null)
{
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
}
return context;
}
private static EndpointDescriptor CreateEndpoint(
string serviceName = "test-service",
string version = "1.0.0",
bool supportsStreaming = false)
{
return new EndpointDescriptor
{
ServiceName = serviceName,
Version = version,
Method = "GET",
Path = "/api/test",
SupportsStreaming = supportsStreaming
};
}
private static ConnectionState CreateConnection(
string connectionId = "conn-1",
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
{
return new ConnectionState
{
ConnectionId = connectionId,
Instance = new InstanceDescriptor
{
InstanceId = $"inst-{connectionId}",
ServiceName = "test-service",
Version = "1.0.0",
Region = "us-east-1"
},
Status = status,
TransportType = TransportType.InMemory
};
}
private static RoutingDecision CreateDecision(
EndpointDescriptor? endpoint = null,
ConnectionState? connection = null,
TimeSpan? timeout = null)
{
return new RoutingDecision
{
Endpoint = endpoint ?? CreateEndpoint(),
Connection = connection ?? CreateConnection(),
TransportType = TransportType.InMemory,
EffectiveTimeout = timeout ?? TimeSpan.FromSeconds(30)
};
}
private static Frame CreateResponseFrame(
string requestId = "test-request",
int statusCode = 200,
Dictionary<string, string>? headers = null,
byte[]? payload = null)
{
var response = new ResponseFrame
{
RequestId = requestId,
StatusCode = statusCode,
Headers = headers ?? new Dictionary<string, string>(),
Payload = payload ?? []
};
return FrameConverter.ToFrame(response);
}
#region Missing Routing Decision Tests
[Fact]
public async Task Invoke_WithNoRoutingDecision_Returns500()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(decision: null);
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
_nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
}
[Fact]
public async Task Invoke_WithNoRoutingDecision_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var context = CreateHttpContext(decision: null);
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Routing decision missing");
}
#endregion
#region Successful Request/Response Tests
[Fact]
public async Task Invoke_WithSuccessfulResponse_ForwardsStatusCode()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId, statusCode: 201);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(201);
}
[Fact]
public async Task Invoke_WithResponsePayload_WritesToResponseBody()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
var responsePayload = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId, payload: responsePayload);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Be("{\"result\":\"success\"}");
}
[Fact]
public async Task Invoke_WithResponseHeaders_ForwardsHeaders()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
var responseHeaders = new Dictionary<string, string>
{
["X-Custom-Header"] = "CustomValue",
["Content-Type"] = "application/json"
};
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Headers.Should().ContainKey("X-Custom-Header");
context.Response.Headers["X-Custom-Header"].ToString().Should().Be("CustomValue");
context.Response.Headers["Content-Type"].ToString().Should().Be("application/json");
}
[Fact]
public async Task Invoke_WithTransferEncodingHeader_DoesNotForward()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
var responseHeaders = new Dictionary<string, string>
{
["Transfer-Encoding"] = "chunked",
["X-Custom-Header"] = "CustomValue"
};
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Headers.Should().NotContainKey("Transfer-Encoding");
context.Response.Headers.Should().ContainKey("X-Custom-Header");
}
[Fact]
public async Task Invoke_WithRequestBody_SendsBodyInFrame()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var requestBody = Encoding.UTF8.GetBytes("{\"data\":\"test\"}");
var context = CreateHttpContext(decision: decision, body: requestBody);
byte[]? capturedPayload = null;
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
capturedPayload = requestFrame?.Payload.ToArray();
})
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
capturedPayload.Should().BeEquivalentTo(requestBody);
}
[Fact]
public async Task Invoke_WithRequestHeaders_ForwardsHeadersInFrame()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
context.Request.Headers["X-Request-Id"] = "req-123";
context.Request.Headers["Accept"] = "application/json";
IReadOnlyDictionary<string, string>? capturedHeaders = null;
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
capturedHeaders = requestFrame?.Headers;
})
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
capturedHeaders.Should().NotBeNull();
capturedHeaders.Should().ContainKey("X-Request-Id");
capturedHeaders!["X-Request-Id"].Should().Be("req-123");
}
#endregion
#region Timeout Tests
[Fact]
public async Task Invoke_WithTimeout_Returns504()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
}
[Fact]
public async Task Invoke_WithTimeout_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Upstream timeout");
responseBody.Should().Contain("test-service");
}
[Fact]
public async Task Invoke_WithTimeout_SendsCancelFrame()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
_transportClientMock.Verify(t => t.SendCancelAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Guid>(),
CancelReasons.Timeout), Times.Once);
}
#endregion
#region Upstream Error Tests
[Fact]
public async Task Invoke_WithUpstreamError_Returns502()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Connection failed"));
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
}
[Fact]
public async Task Invoke_WithUpstreamError_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Connection failed"));
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Upstream error");
responseBody.Should().Contain("Connection failed");
}
#endregion
#region Invalid Response Tests
[Fact]
public async Task Invoke_WithInvalidResponseFrame_Returns502()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
// Return a malformed frame that cannot be parsed as ResponseFrame
var invalidFrame = new Frame
{
Type = FrameType.Heartbeat, // Wrong type
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(invalidFrame);
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
}
[Fact]
public async Task Invoke_WithInvalidResponseFrame_WritesErrorResponse()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
var invalidFrame = new Frame
{
Type = FrameType.Cancel, // Wrong type
CorrelationId = "test",
Payload = Array.Empty<byte>()
};
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(invalidFrame);
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
var responseBody = await reader.ReadToEndAsync();
responseBody.Should().Contain("Invalid upstream response");
}
#endregion
#region Connection Ping Update Tests
[Fact]
public async Task Invoke_WithSuccessfulResponse_UpdatesConnectionPing()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
_routingStateMock.Verify(r => r.UpdateConnection(
"conn-1",
It.IsAny<Action<ConnectionState>>()), Times.Once);
}
#endregion
#region Streaming Tests
[Fact]
public async Task Invoke_WithStreamingEndpoint_UsesSendStreamingAsync()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(supportsStreaming: true);
var decision = CreateDecision(endpoint: endpoint);
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendStreamingAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<Stream>(),
It.IsAny<Func<Stream, Task>>(),
It.IsAny<PayloadLimits>(),
It.IsAny<CancellationToken>()))
.Callback<ConnectionState, Frame, Stream, Func<Stream, Task>, PayloadLimits, CancellationToken>(
async (conn, req, requestBody, readResponse, limits, ct) =>
{
// Simulate streaming response
using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes("streamed data"));
await readResponse(responseStream);
})
.Returns(Task.CompletedTask);
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
_transportClientMock.Verify(t => t.SendStreamingAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<Stream>(),
It.IsAny<Func<Stream, Task>>(),
It.IsAny<PayloadLimits>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Invoke_StreamingWithTimeout_Returns504()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(supportsStreaming: true);
var decision = CreateDecision(endpoint: endpoint, timeout: TimeSpan.FromMilliseconds(50));
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendStreamingAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<Stream>(),
It.IsAny<Func<Stream, Task>>(),
It.IsAny<PayloadLimits>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
}
[Fact]
public async Task Invoke_StreamingWithUpstreamError_Returns502()
{
// Arrange
var middleware = CreateMiddleware();
var endpoint = CreateEndpoint(supportsStreaming: true);
var decision = CreateDecision(endpoint: endpoint);
var context = CreateHttpContext(decision: decision);
_transportClientMock.Setup(t => t.SendStreamingAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<Stream>(),
It.IsAny<Func<Stream, Task>>(),
It.IsAny<PayloadLimits>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Streaming failed"));
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
}
#endregion
#region Query String Tests
[Fact]
public async Task Invoke_WithQueryString_IncludesInRequestPath()
{
// Arrange
var middleware = CreateMiddleware();
var decision = CreateDecision();
var context = CreateHttpContext(decision: decision, path: "/api/test");
context.Request.QueryString = new QueryString("?key=value&other=123");
string? capturedPath = null;
_transportClientMock.Setup(t => t.SendRequestAsync(
It.IsAny<ConnectionState>(),
It.IsAny<Frame>(),
It.IsAny<TimeSpan>(),
It.IsAny<CancellationToken>()))
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
capturedPath = requestFrame?.Path;
})
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
{
var requestFrame = FrameConverter.ToRequestFrame(req);
return CreateResponseFrame(requestId: requestFrame!.RequestId);
});
// Act
await middleware.Invoke(
context,
_transportClientMock.Object,
_routingStateMock.Object);
// Assert
capturedPath.Should().Be("/api/test?key=value&other=123");
}
#endregion
}