stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search
This commit is contained in:
@@ -46,7 +46,7 @@ public sealed class AuthorizationMiddlewareTests
|
||||
public async Task InvokeAsync_NoClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateHttpContextWithEndpoint();
|
||||
var context = CreateHttpContextWithEndpoint([new Claim("sub", "alice")]);
|
||||
_claimsStore
|
||||
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
|
||||
.Returns(Array.Empty<ClaimRequirement>());
|
||||
@@ -59,6 +59,27 @@ public sealed class AuthorizationMiddlewareTests
|
||||
context.Response.StatusCode.Should().NotBe(403);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_LegacyMetadataWithoutAuthFlag_FailsClosedWith401()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
AllowAnonymous = false,
|
||||
RequiresAuthentication = false
|
||||
};
|
||||
|
||||
var context = CreateHttpContextWithEndpoint(endpoint: endpoint);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
_next.Verify(n => n(It.IsAny<HttpContext>()), Times.Never);
|
||||
context.Response.StatusCode.Should().Be(401);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_UserHasRequiredClaims_CallsNext()
|
||||
{
|
||||
@@ -215,7 +236,7 @@ public sealed class AuthorizationMiddlewareTests
|
||||
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateHttpContextWithEndpoint();
|
||||
var context = CreateHttpContextWithEndpoint([new Claim("sub", "alice")]);
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
_claimsStore
|
||||
@@ -233,18 +254,39 @@ public sealed class AuthorizationMiddlewareTests
|
||||
context.Response.ContentType.Should().Contain("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AllowAnonymousEndpoint_CallsNextWithoutAuthentication()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
AllowAnonymous = true
|
||||
};
|
||||
|
||||
var context = CreateHttpContextWithEndpoint(endpoint: endpoint);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
_next.Verify(n => n(context), Times.Once);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContextWithEndpoint(Claim[]? userClaims = null)
|
||||
private static HttpContext CreateHttpContextWithEndpoint(
|
||||
Claim[]? userClaims = null,
|
||||
EndpointDescriptor? endpoint = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
// Set resolved endpoint
|
||||
var endpoint = new EndpointDescriptor
|
||||
endpoint ??= new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
|
||||
@@ -56,21 +56,21 @@ public sealed class MessagingTransportIntegrationTests
|
||||
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
|
||||
|
||||
var transportClient = new GatewayTransportClient(
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
messagingServer);
|
||||
|
||||
// Act & Assert - construction should succeed with messaging server
|
||||
var hostedService = new GatewayHostedService(
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
routingState.Object,
|
||||
transportClient,
|
||||
claimsStore.Object,
|
||||
gatewayOptions,
|
||||
new GatewayServiceStatus(),
|
||||
NullLogger<GatewayHostedService>.Instance,
|
||||
tcpServer: tcpServer,
|
||||
tlsServer: tlsServer,
|
||||
openApiCache: null,
|
||||
messagingServer: messagingServer);
|
||||
|
||||
@@ -92,21 +92,21 @@ public sealed class MessagingTransportIntegrationTests
|
||||
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
|
||||
|
||||
var transportClient = new GatewayTransportClient(
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
messagingServer: null);
|
||||
|
||||
// Act & Assert - construction should succeed without messaging server
|
||||
var hostedService = new GatewayHostedService(
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
routingState.Object,
|
||||
transportClient,
|
||||
claimsStore.Object,
|
||||
gatewayOptions,
|
||||
new GatewayServiceStatus(),
|
||||
NullLogger<GatewayHostedService>.Instance,
|
||||
tcpServer: tcpServer,
|
||||
tlsServer: tlsServer,
|
||||
openApiCache: null,
|
||||
messagingServer: null);
|
||||
|
||||
@@ -132,9 +132,9 @@ public sealed class MessagingTransportIntegrationTests
|
||||
|
||||
// Act
|
||||
var transportClient = new GatewayTransportClient(
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
messagingServer);
|
||||
|
||||
// Assert
|
||||
@@ -152,9 +152,9 @@ public sealed class MessagingTransportIntegrationTests
|
||||
|
||||
// No messaging server provided
|
||||
var transportClient = new GatewayTransportClient(
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
tcpServer,
|
||||
tlsServer,
|
||||
NullLogger<GatewayTransportClient>.Instance,
|
||||
messagingServer: null);
|
||||
|
||||
var connection = new ConnectionState
|
||||
@@ -193,6 +193,7 @@ public sealed class MessagingTransportIntegrationTests
|
||||
Assert.Equal("localhost:6379", options.ConnectionString);
|
||||
Assert.Null(options.Database);
|
||||
Assert.Equal("router:requests:{service}", options.RequestQueueTemplate);
|
||||
Assert.Equal("gateway-control", options.GatewayControlQueueServiceName);
|
||||
Assert.Equal("router:responses", options.ResponseQueueName);
|
||||
Assert.Equal("router-gateway", options.ConsumerGroup);
|
||||
Assert.Equal("30s", options.RequestTimeout);
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.Routing;
|
||||
using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Middleware;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RouteDispatchMiddlewareMicroserviceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceRouteWithTranslatesTo_SetsTranslatedPath()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/advisory-ai/adapters",
|
||||
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/advisory-ai/adapters/openai/chat/completions";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(
|
||||
"/v1/advisory-ai/adapters/openai/chat/completions",
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
|
||||
Assert.Equal(
|
||||
"advisoryai",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceRouteWithoutTranslatesTo_DoesNotSetTranslatedPath()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/timeline"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/timeline";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
|
||||
Assert.Equal(
|
||||
"timeline",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceRouteWithGenericHost_PrefersPathHintForTargetService()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/vulnexplorer",
|
||||
TranslatesTo = "http://api.stella-ops.local"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/vulnexplorer";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(
|
||||
"vulnexplorer",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceRouteWithDefaultTimeout_SetsRouteTimeoutContextItem()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/timeline",
|
||||
DefaultTimeout = "15s"
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/api/v1/timeline/events";
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.Items.TryGetValue(RouterHttpContextKeys.RouteDefaultTimeout, out var timeoutObj));
|
||||
Assert.IsType<TimeSpan>(timeoutObj);
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), (TimeSpan)timeoutObj!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceRoute_BrowserNavigation_ServesSpaFallback()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-spa-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempDir, "index.html"),
|
||||
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
|
||||
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/policy",
|
||||
TranslatesTo = "http://policy-gateway.stella-ops.local"
|
||||
},
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/",
|
||||
TranslatesTo = tempDir,
|
||||
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/policy";
|
||||
context.Request.Headers.Accept = "text/html,application/xhtml+xml";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.False(nextCalled);
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
context.Response.Body.Position = 0;
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
Assert.Contains("SPA Root", body, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-spa-api-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempDir, "index.html"),
|
||||
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
|
||||
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/policy",
|
||||
TranslatesTo = "http://policy-gateway.stella-ops.local/api/v1/policy"
|
||||
},
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/",
|
||||
TranslatesTo = tempDir,
|
||||
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/policy/check";
|
||||
context.Request.Headers.Accept = "text/html,application/xhtml+xml";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(nextCalled);
|
||||
Assert.Equal(
|
||||
"policy-gateway",
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0349-T | DONE | Revalidated 2026-01-07; test coverage audit for Router Gateway WebService tests. |
|
||||
| AUDIT-0349-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |
|
||||
|
||||
@@ -223,6 +223,67 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_RuntimeSchemasOverrideGeneratedSchemas_WhenSchemaIdsMatch()
|
||||
{
|
||||
// Arrange
|
||||
_options.Routers.Add(new RouterEndpointConfig
|
||||
{
|
||||
Host = "localhost",
|
||||
Port = 5000,
|
||||
TransportType = TransportType.InMemory
|
||||
});
|
||||
|
||||
var schemaId = "shared_schema";
|
||||
var runtimeSchema = new SchemaDefinition
|
||||
{
|
||||
SchemaId = schemaId,
|
||||
SchemaJson = "{\"type\":\"object\",\"properties\":{\"status\":{\"type\":\"string\"}}}",
|
||||
ETag = "runtime"
|
||||
};
|
||||
var generatedSchema = new SchemaDefinition
|
||||
{
|
||||
SchemaId = schemaId,
|
||||
SchemaJson = "{invalid-json",
|
||||
ETag = "generated"
|
||||
};
|
||||
|
||||
var discoveryProvider = new TestSchemaDiscoveryProvider(
|
||||
endpoints: [],
|
||||
schemas: new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
[schemaId] = runtimeSchema
|
||||
});
|
||||
|
||||
var generatedProviderMock = new Mock<IGeneratedEndpointProvider>();
|
||||
generatedProviderMock.Setup(provider => provider.GetSchemaDefinitions())
|
||||
.Returns(new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
[schemaId] = generatedSchema
|
||||
});
|
||||
|
||||
using var manager = new RouterConnectionManager(
|
||||
Options.Create(_options),
|
||||
discoveryProvider,
|
||||
_requestDispatcherMock.Object,
|
||||
_transportMock.Object,
|
||||
NullLogger<RouterConnectionManager>.Instance,
|
||||
generatedProviderMock.Object);
|
||||
|
||||
// Act
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
manager.Connections.Should().HaveCount(1);
|
||||
manager.Connections[0].Schemas.Should().ContainKey(schemaId);
|
||||
manager.Connections[0].Schemas[schemaId].SchemaJson.Should().Be(runtimeSchema.SchemaJson);
|
||||
manager.Connections[0].Schemas[schemaId].ETag.Should().Be(runtimeSchema.ETag);
|
||||
|
||||
// Cleanup
|
||||
await manager.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
@@ -348,4 +409,24 @@ public sealed class RouterConnectionManagerTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestSchemaDiscoveryProvider :
|
||||
IEndpointDiscoveryProvider,
|
||||
IEndpointSchemaDefinitionProvider
|
||||
{
|
||||
private readonly IReadOnlyList<EndpointDescriptor> _endpoints;
|
||||
private readonly IReadOnlyDictionary<string, SchemaDefinition> _schemas;
|
||||
|
||||
public TestSchemaDiscoveryProvider(
|
||||
IReadOnlyList<EndpointDescriptor> endpoints,
|
||||
IReadOnlyDictionary<string, SchemaDefinition> schemas)
|
||||
{
|
||||
_endpoints = endpoints;
|
||||
_schemas = schemas;
|
||||
}
|
||||
|
||||
public IReadOnlyList<EndpointDescriptor> DiscoverEndpoints() => _endpoints;
|
||||
|
||||
public IReadOnlyDictionary<string, SchemaDefinition> DiscoverSchemaDefinitions() => _schemas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Metadata;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Microservice.AspNetCore;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.AspNet.Tests;
|
||||
|
||||
public sealed class AspNetCoreEndpointDiscoveryProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_UsesIHttpMethodMetadata()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/timeline"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
|
||||
displayName: "Timeline GET");
|
||||
|
||||
var dataSource = new StaticEndpointDataSource(routeEndpoint);
|
||||
var options = new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
};
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
dataSource,
|
||||
options,
|
||||
new AllowAnonymousClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var endpoints = provider.DiscoverEndpoints();
|
||||
|
||||
Assert.Single(endpoints);
|
||||
Assert.Equal("GET", endpoints[0].Method);
|
||||
Assert.Equal("/api/v1/timeline", endpoints[0].Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverSchemaDefinitions_EmitsOpenApiMetadataAndSchemas()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/timeline/events"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(
|
||||
new CustomHttpMethodMetadata("POST"),
|
||||
new CustomSummaryMetadata("Ingest timeline event"),
|
||||
new CustomDescriptionMetadata("Indexes a tenant timeline event."),
|
||||
new CustomTagsMetadata("timeline"),
|
||||
new CustomAcceptsMetadata(typeof(TimelineEnvelope), false, "application/json"),
|
||||
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TimelineIngestedResponse), "application/json")),
|
||||
displayName: "Timeline Ingest");
|
||||
|
||||
var dataSource = new StaticEndpointDataSource(routeEndpoint);
|
||||
var options = new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
};
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
dataSource,
|
||||
options,
|
||||
new AllowAnonymousClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var discovered = provider.DiscoverAspNetEndpoints();
|
||||
var endpoint = Assert.Single(discovered);
|
||||
|
||||
Assert.NotNull(endpoint.SchemaInfo);
|
||||
Assert.Equal("Ingest timeline event", endpoint.SchemaInfo!.Summary);
|
||||
Assert.Equal("Indexes a tenant timeline event.", endpoint.SchemaInfo.Description);
|
||||
Assert.Contains("timeline", endpoint.SchemaInfo.Tags);
|
||||
Assert.NotNull(endpoint.SchemaInfo.RequestSchemaId);
|
||||
Assert.NotNull(endpoint.SchemaInfo.ResponseSchemaId);
|
||||
Assert.Equal(StatusCodes.Status200OK, endpoint.SchemaInfo.ResponseStatusCode);
|
||||
|
||||
var schemaProvider = Assert.IsAssignableFrom<IEndpointSchemaDefinitionProvider>(provider);
|
||||
var schemas = schemaProvider.DiscoverSchemaDefinitions();
|
||||
|
||||
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo.RequestSchemaId!));
|
||||
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo.ResponseSchemaId!));
|
||||
Assert.Contains("\"eventId\"", schemas[endpoint.SchemaInfo.RequestSchemaId!].SchemaJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"status\"", schemas[endpoint.SchemaInfo.ResponseSchemaId!].SchemaJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverSchemaDefinitions_PrefersTypedProducesMetadataOverIResult()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/timeline"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(
|
||||
new CustomHttpMethodMetadata("GET"),
|
||||
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(IResult), "application/json"),
|
||||
new CustomProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TimelineIngestedResponse), "application/json")),
|
||||
displayName: "Timeline Query");
|
||||
|
||||
var dataSource = new StaticEndpointDataSource(routeEndpoint);
|
||||
var options = new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
};
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
dataSource,
|
||||
options,
|
||||
new AllowAnonymousClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var endpoint = Assert.Single(provider.DiscoverAspNetEndpoints());
|
||||
Assert.NotNull(endpoint.SchemaInfo?.ResponseSchemaId);
|
||||
|
||||
var schemaProvider = Assert.IsAssignableFrom<IEndpointSchemaDefinitionProvider>(provider);
|
||||
var schemas = schemaProvider.DiscoverSchemaDefinitions();
|
||||
|
||||
Assert.True(schemas.ContainsKey(endpoint.SchemaInfo!.ResponseSchemaId!));
|
||||
Assert.Contains("\"status\"", schemas[endpoint.SchemaInfo.ResponseSchemaId!].SchemaJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverSchemaDefinitions_PreservesSuccessResponseStatusCodeFromProducesMetadata()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/timeline/events"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(
|
||||
new CustomHttpMethodMetadata("POST"),
|
||||
new CustomAcceptsMetadata(typeof(TimelineEnvelope), false, "application/json"),
|
||||
new CustomProducesResponseTypeMetadata(StatusCodes.Status201Created, typeof(TimelineIngestedResponse), "application/json")),
|
||||
displayName: "Timeline Ingest Created");
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
new StaticEndpointDataSource(routeEndpoint),
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
},
|
||||
new AllowAnonymousClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var endpoint = Assert.Single(provider.DiscoverAspNetEndpoints());
|
||||
Assert.NotNull(endpoint.SchemaInfo?.ResponseSchemaId);
|
||||
Assert.Equal(StatusCodes.Status201Created, endpoint.SchemaInfo!.ResponseStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_AuthorizeWithoutClaims_StillRequiresAuthentication()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/protected"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
|
||||
displayName: "Protected GET");
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
new StaticEndpointDataSource(routeEndpoint),
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.AllowAuthenticated
|
||||
},
|
||||
new AuthorizeOnlyClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var endpoint = Assert.Single(provider.DiscoverEndpoints());
|
||||
Assert.False(endpoint.AllowAnonymous);
|
||||
Assert.True(endpoint.RequiresAuthentication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_OnMissingAuthorizationWarnAndAllow_RequiresAuthentication()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/missing-auth"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
|
||||
displayName: "Missing Auth GET");
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
new StaticEndpointDataSource(routeEndpoint),
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.WarnAndAllow
|
||||
},
|
||||
new NoAuthorizationClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
var endpoint = Assert.Single(provider.DiscoverEndpoints());
|
||||
Assert.False(endpoint.AllowAnonymous);
|
||||
Assert.True(endpoint.RequiresAuthentication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_OnMissingAuthorizationRequireExplicit_Throws()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/missing-auth"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new CustomHttpMethodMetadata("GET")),
|
||||
displayName: "Missing Auth GET");
|
||||
|
||||
var provider = new AspNetCoreEndpointDiscoveryProvider(
|
||||
new StaticEndpointDataSource(routeEndpoint),
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
OnMissingAuthorization = MissingAuthorizationBehavior.RequireExplicit
|
||||
},
|
||||
new NoAuthorizationClaimMapper(),
|
||||
NullLogger<AspNetCoreEndpointDiscoveryProvider>.Instance);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => provider.DiscoverEndpoints());
|
||||
}
|
||||
|
||||
private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
|
||||
{
|
||||
private readonly IReadOnlyList<Endpoint> _endpoints = endpoints;
|
||||
|
||||
public override IReadOnlyList<Endpoint> Endpoints => _endpoints;
|
||||
|
||||
public override IChangeToken GetChangeToken() =>
|
||||
new CancellationChangeToken(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class CustomHttpMethodMetadata(params string[] methods) : IHttpMethodMetadata
|
||||
{
|
||||
public IReadOnlyList<string> HttpMethods { get; } = methods;
|
||||
|
||||
public bool AcceptCorsPreflight => false;
|
||||
}
|
||||
|
||||
private sealed class AllowAnonymousClaimMapper : IAuthorizationClaimMapper
|
||||
{
|
||||
public Task<AuthorizationMappingResult> MapAsync(
|
||||
RouteEndpoint endpoint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Map(endpoint));
|
||||
}
|
||||
|
||||
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
|
||||
{
|
||||
return new AuthorizationMappingResult
|
||||
{
|
||||
AllowAnonymous = true,
|
||||
Source = AuthorizationSource.AspNetMetadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AuthorizeOnlyClaimMapper : IAuthorizationClaimMapper
|
||||
{
|
||||
public Task<AuthorizationMappingResult> MapAsync(RouteEndpoint endpoint, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Map(endpoint));
|
||||
|
||||
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
|
||||
=> new()
|
||||
{
|
||||
AllowAnonymous = false,
|
||||
Source = AuthorizationSource.AspNetMetadata
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class NoAuthorizationClaimMapper : IAuthorizationClaimMapper
|
||||
{
|
||||
public Task<AuthorizationMappingResult> MapAsync(RouteEndpoint endpoint, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Map(endpoint));
|
||||
|
||||
public AuthorizationMappingResult Map(RouteEndpoint endpoint)
|
||||
=> new()
|
||||
{
|
||||
AllowAnonymous = false,
|
||||
Source = AuthorizationSource.None
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record TimelineEnvelope(string EventId, string TenantId);
|
||||
|
||||
private sealed record TimelineIngestedResponse(string EventId, string Status);
|
||||
|
||||
private sealed class CustomSummaryMetadata(string summary) : IEndpointSummaryMetadata
|
||||
{
|
||||
public string Summary { get; } = summary;
|
||||
}
|
||||
|
||||
private sealed class CustomDescriptionMetadata(string description) : IEndpointDescriptionMetadata
|
||||
{
|
||||
public string Description { get; } = description;
|
||||
}
|
||||
|
||||
private sealed class CustomTagsMetadata(params string[] tags) : ITagsMetadata
|
||||
{
|
||||
public IReadOnlyList<string> Tags { get; } = tags;
|
||||
}
|
||||
|
||||
private sealed class CustomAcceptsMetadata(
|
||||
Type requestType,
|
||||
bool isOptional,
|
||||
params string[] contentTypes) : IAcceptsMetadata
|
||||
{
|
||||
public Type? RequestType { get; } = requestType;
|
||||
public IReadOnlyList<string> ContentTypes { get; } = contentTypes;
|
||||
public bool IsOptional { get; } = isOptional;
|
||||
}
|
||||
|
||||
private sealed class CustomProducesResponseTypeMetadata(
|
||||
int statusCode,
|
||||
Type responseType,
|
||||
params string[] contentTypes) : IProducesResponseTypeMetadata
|
||||
{
|
||||
public Type? Type { get; } = responseType;
|
||||
public int StatusCode { get; } = statusCode;
|
||||
public string? Description => null;
|
||||
public IEnumerable<string> ContentTypes { get; } = contentTypes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Matching;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Microservice.AspNetCore;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Identity;
|
||||
|
||||
namespace StellaOps.Router.AspNet.Tests;
|
||||
|
||||
public sealed class AspNetRouterRequestDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_GatewayEnforcedWithoutEnvelope_ReturnsForbidden()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
async context => await context.Response.WriteAsync("ok"),
|
||||
RoutePatternFactory.Parse("/api/v1/ping"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new HttpMethodMetadata(["GET"])),
|
||||
displayName: "Ping");
|
||||
|
||||
var dispatcher = CreateDispatcher(
|
||||
routeEndpoint,
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
AuthorizationTrustMode = GatewayAuthorizationTrustMode.GatewayEnforced,
|
||||
IdentityEnvelopeSigningKey = "test-signing-key-123"
|
||||
});
|
||||
|
||||
var response = await dispatcher.DispatchAsync(new RequestFrame
|
||||
{
|
||||
RequestId = "req-1",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/ping",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
});
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_GatewayEnforcedWithValidEnvelope_DispatchesAndSetsIdentity()
|
||||
{
|
||||
var routeEndpoint = new RouteEndpoint(
|
||||
async context =>
|
||||
{
|
||||
var subject = context.User.FindFirst("sub")?.Value ?? "missing";
|
||||
await context.Response.WriteAsync(subject);
|
||||
},
|
||||
RoutePatternFactory.Parse("/api/v1/ping"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(new HttpMethodMetadata(["GET"])),
|
||||
displayName: "Ping");
|
||||
|
||||
const string signingKey = "test-signing-key-123";
|
||||
var dispatcher = CreateDispatcher(
|
||||
routeEndpoint,
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local",
|
||||
AuthorizationTrustMode = GatewayAuthorizationTrustMode.GatewayEnforced,
|
||||
IdentityEnvelopeSigningKey = signingKey
|
||||
});
|
||||
|
||||
var envelope = new GatewayIdentityEnvelope
|
||||
{
|
||||
Issuer = "gateway",
|
||||
Subject = "alice",
|
||||
Tenant = "tenant-a",
|
||||
Scopes = ["timeline.read"],
|
||||
Roles = ["reader"],
|
||||
CorrelationId = "corr-1",
|
||||
IssuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1)
|
||||
};
|
||||
var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, signingKey);
|
||||
|
||||
var response = await dispatcher.DispatchAsync(new RequestFrame
|
||||
{
|
||||
RequestId = "req-2",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/ping",
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Identity-Envelope"] = signature.Payload,
|
||||
["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature
|
||||
},
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
});
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
|
||||
Assert.Equal("alice", Encoding.UTF8.GetString(response.Payload.ToArray()));
|
||||
}
|
||||
|
||||
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<EndpointSelector, NoOpEndpointSelector>();
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
return new AspNetRouterRequestDispatcher(
|
||||
serviceProvider,
|
||||
new StaticEndpointDataSource(endpoint),
|
||||
options,
|
||||
NullLogger<AspNetRouterRequestDispatcher>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
|
||||
{
|
||||
private readonly IReadOnlyList<Endpoint> _endpoints = endpoints;
|
||||
|
||||
public override IReadOnlyList<Endpoint> Endpoints => _endpoints;
|
||||
|
||||
public override IChangeToken GetChangeToken()
|
||||
=> new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class NoOpEndpointSelector : EndpointSelector
|
||||
{
|
||||
public override Task SelectAsync(HttpContext httpContext, CandidateSet candidates) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Microservice.AspNetCore;
|
||||
|
||||
namespace StellaOps.Router.AspNet.Tests;
|
||||
|
||||
public sealed class DefaultAuthorizationClaimMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MapAsync_WithAuthorizationPolicyMetadata_MapsScopeClaimsAndMarksAspNetSource()
|
||||
{
|
||||
var endpoint = CreateEndpoint(
|
||||
new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new TestScopeRequirement("timeline:read", "timeline:write"))
|
||||
.Build());
|
||||
|
||||
var mapper = new DefaultAuthorizationClaimMapper(
|
||||
new DictionaryPolicyProvider(),
|
||||
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
|
||||
|
||||
var result = await mapper.MapAsync(endpoint);
|
||||
|
||||
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
|
||||
Assert.True(result.HasAuthorization);
|
||||
Assert.False(result.AllowAnonymous);
|
||||
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
|
||||
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAsync_WithAuthorizeDataPolicy_ResolvesPolicyClaimsFromProvider()
|
||||
{
|
||||
const string policyName = "timeline.read";
|
||||
var endpoint = CreateEndpoint(new AuthorizeAttribute { Policy = policyName });
|
||||
|
||||
var mapper = new DefaultAuthorizationClaimMapper(
|
||||
new DictionaryPolicyProvider(
|
||||
new Dictionary<string, AuthorizationPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[policyName] = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireClaim("scope", "timeline:read")
|
||||
.Build()
|
||||
}),
|
||||
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
|
||||
|
||||
var result = await mapper.MapAsync(endpoint);
|
||||
|
||||
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
|
||||
Assert.Contains(policyName, result.Policies);
|
||||
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_WithAuthorizationPolicyMetadata_MapsScopeClaims()
|
||||
{
|
||||
var endpoint = CreateEndpoint(
|
||||
new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new TestScopeRequirement("timeline:read"))
|
||||
.Build());
|
||||
|
||||
var mapper = new DefaultAuthorizationClaimMapper(
|
||||
new DictionaryPolicyProvider(),
|
||||
NullLogger<DefaultAuthorizationClaimMapper>.Instance);
|
||||
|
||||
var result = mapper.Map(endpoint);
|
||||
|
||||
Assert.Equal(AuthorizationSource.AspNetMetadata, result.Source);
|
||||
Assert.Contains(result.Claims, claim => claim.Type == "scope" && claim.Value == "timeline:read");
|
||||
}
|
||||
|
||||
private static RouteEndpoint CreateEndpoint(params object[] metadata)
|
||||
{
|
||||
return new RouteEndpoint(
|
||||
_ => Task.CompletedTask,
|
||||
RoutePatternFactory.Parse("/api/v1/timeline"),
|
||||
order: 0,
|
||||
new EndpointMetadataCollection(metadata),
|
||||
displayName: "timeline-endpoint");
|
||||
}
|
||||
|
||||
private sealed class DictionaryPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, AuthorizationPolicy> _policies;
|
||||
|
||||
public DictionaryPolicyProvider(IReadOnlyDictionary<string, AuthorizationPolicy>? policies = null)
|
||||
{
|
||||
_policies = policies ?? new Dictionary<string, AuthorizationPolicy>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
return Task.FromResult(_policies.TryGetValue(policyName, out var policy) ? policy : null);
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
|
||||
{
|
||||
return Task.FromResult(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
|
||||
}
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
|
||||
{
|
||||
return Task.FromResult<AuthorizationPolicy?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestScopeRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public TestScopeRequirement(params string[] requiredScopes)
|
||||
{
|
||||
RequiredScopes = requiredScopes;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> RequiredScopes { get; }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tcp\StellaOps.Router.Transport.Tcp.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Transport.Messaging.Options;
|
||||
using StellaOps.Router.Transport.Tcp;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.AspNet.Tests;
|
||||
|
||||
public sealed class StellaRouterIntegrationHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddRouterMicroservice_WhenRouterDisabled_ReturnsFalse()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "false"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_WhenEnabledWithoutGateway_UsesDefaults()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
|
||||
var transport = provider.GetRequiredService<IMicroserviceTransport>();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Single(options.Routers);
|
||||
Assert.Equal("router.stella-ops.local", options.Routers[0].Host);
|
||||
Assert.Equal(9100, options.Routers[0].Port);
|
||||
Assert.Equal(TransportType.Messaging, options.Routers[0].TransportType);
|
||||
Assert.Equal("MessagingTransportClient", transport.GetType().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_WhenSectionMissing_FallsBackToGlobalRouterSection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Router:Enabled"] = "true",
|
||||
["Router:Region"] = "local",
|
||||
["Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["Router:Gateways:0:Port"] = "9100",
|
||||
["Router:Gateways:0:TransportType"] = "Messaging",
|
||||
["Router:Messaging:PluginDirectory"] = AppContext.BaseDirectory,
|
||||
["Router:Messaging:SearchPattern"] = "StellaOps.Messaging.Transport.*.dll",
|
||||
["Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
|
||||
["Router:TransportPlugins:Directory"] = AppContext.BaseDirectory,
|
||||
["Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.*.dll"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Single(options.Routers);
|
||||
Assert.Equal("router.stella-ops.local", options.Routers[0].Host);
|
||||
Assert.Equal(TransportType.Messaging, options.Routers[0].TransportType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRouterMicroservice_WhenRouterOptionsSectionMissing_ThrowsArgumentException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true"
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: " "));
|
||||
|
||||
Assert.Contains("Router options section is required", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRouterMicroservice_WhenTransportPluginIsUnavailable_Throws()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var pluginDir = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "stellaops-router-plugin-missing-" + Guid.NewGuid().ToString("N")));
|
||||
|
||||
try
|
||||
{
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "RabbitMq",
|
||||
["TimelineIndexer:Router:TransportPlugins:Directory"] = pluginDir.FullName,
|
||||
["TimelineIndexer:Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.RabbitMq.dll"
|
||||
});
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router"));
|
||||
|
||||
Assert.Contains("Transport plugin 'rabbitmq' is not available", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pluginDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_TcpGateway_ConfiguresTcpClientOptions()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<TcpTransportOptions>>().Value;
|
||||
var transport = provider.GetRequiredService<IMicroserviceTransport>();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("router.stella-ops.local", options.Host);
|
||||
Assert.Equal(9100, options.Port);
|
||||
Assert.Equal("TcpTransportClient", transport.GetType().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_MessagingGateway_ConfiguresMessagingAndValkeyOptions()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Messaging",
|
||||
["TimelineIndexer:Router:Messaging:RequestQueueTemplate"] = "router:requests:{service}",
|
||||
["TimelineIndexer:Router:Messaging:ResponseQueueName"] = "router:responses",
|
||||
["TimelineIndexer:Router:Messaging:ConsumerGroup"] = "timelineindexer",
|
||||
["TimelineIndexer:Router:Messaging:BatchSize"] = "21",
|
||||
["TimelineIndexer:Router:Messaging:RequestTimeout"] = "45s",
|
||||
["TimelineIndexer:Router:Messaging:LeaseDuration"] = "4m",
|
||||
["TimelineIndexer:Router:Messaging:HeartbeatInterval"] = "12s",
|
||||
["TimelineIndexer:Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
|
||||
["TimelineIndexer:Router:Messaging:valkey:Database"] = "2"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var messaging = provider.GetRequiredService<IOptions<MessagingTransportOptions>>().Value;
|
||||
var valkey = provider.GetRequiredService<IOptions<StellaOps.Messaging.Transport.Valkey.ValkeyTransportOptions>>().Value;
|
||||
var transport = provider.GetRequiredService<IMicroserviceTransport>();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
|
||||
Assert.Equal("router:responses", messaging.ResponseQueueName);
|
||||
Assert.Equal("timelineindexer", messaging.ConsumerGroup);
|
||||
Assert.Equal(21, messaging.BatchSize);
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), messaging.RequestTimeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(4), messaging.LeaseDuration);
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), messaging.HeartbeatInterval);
|
||||
Assert.Equal("cache.stella-ops.local:6379", valkey.ConnectionString);
|
||||
Assert.Equal(2, valkey.Database);
|
||||
Assert.Equal("MessagingTransportClient", transport.GetType().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_AssemblyVersion_NormalizesToSemver()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("1.0.0", options.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRouterMicroservice_PreservesAspNetEndpointDiscoveryRegistration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configuration = BuildConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp"
|
||||
});
|
||||
|
||||
var result = services.AddRouterMicroservice(
|
||||
configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
var discoveryDescriptors = services
|
||||
.Where(descriptor => descriptor.ServiceType == typeof(IEndpointDiscoveryProvider))
|
||||
.ToArray();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Single(discoveryDescriptors);
|
||||
Assert.NotNull(discoveryDescriptors[0].ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddRouterMicroservice_DiscoversMappedAspNetEndpoints()
|
||||
{
|
||||
_ = typeof(StellaOps.Router.Transport.Tcp.TcpTransportPlugin);
|
||||
|
||||
var configurationValues = new Dictionary<string, string?>
|
||||
{
|
||||
["TimelineIndexer:Router:Enabled"] = "true",
|
||||
["TimelineIndexer:Router:Region"] = "local",
|
||||
["TimelineIndexer:Router:Gateways:0:Host"] = "router.stella-ops.local",
|
||||
["TimelineIndexer:Router:Gateways:0:Port"] = "9100",
|
||||
["TimelineIndexer:Router:Gateways:0:TransportType"] = "Tcp",
|
||||
["TimelineIndexer:Router:TransportPlugins:Directory"] = AppContext.BaseDirectory,
|
||||
["TimelineIndexer:Router:TransportPlugins:SearchPattern"] = "StellaOps.Router.Transport.*.dll"
|
||||
};
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(configurationValues);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
var routerOptions = builder.Configuration
|
||||
.GetSection("TimelineIndexer:Router")
|
||||
.Get<StellaRouterOptionsBase>();
|
||||
|
||||
builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: "1.0.0",
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.MapGet("/api/v1/timeline", () => "ok").RequireAuthorization();
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
var endpointDataSource = app.Services.GetRequiredService<EndpointDataSource>();
|
||||
var routeEndpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>().ToList();
|
||||
|
||||
Assert.NotEmpty(routeEndpoints);
|
||||
|
||||
var discovery = app.Services.GetRequiredService<IEndpointDiscoveryProvider>();
|
||||
var endpoints = discovery.DiscoverEndpoints();
|
||||
|
||||
Assert.Contains(endpoints, endpoint =>
|
||||
endpoint.Method == "GET" &&
|
||||
endpoint.Path == "/api/v1/timeline");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static IConfiguration BuildConfiguration(Dictionary<string, string?> values)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public sealed class StellaRouterOptionsTests
|
||||
Assert.True(options.EnableStellaEndpoints);
|
||||
Assert.Equal(DispatchStrategy.AspNetFirst, options.DispatchStrategy);
|
||||
Assert.Equal(AuthorizationMappingStrategy.Hybrid, options.AuthorizationMapping);
|
||||
Assert.Equal(MissingAuthorizationBehavior.RequireExplicit, options.OnMissingAuthorization);
|
||||
Assert.Equal(MissingAuthorizationBehavior.WarnAndAllow, options.OnMissingAuthorization);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.HeartbeatInterval);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| RVM-02 | DONE | Added `AddRouterMicroservice()` DI tests for disabled mode, gateway validation, TCP registration, and Valkey messaging options wiring via plugin-based transport activation; extended ASP.NET discovery tests for OpenAPI metadata/schema extraction and typed response-schema selection fallback. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Router.AspNet.Tests;
|
||||
|
||||
public sealed class TransportPluginGuardrailTests
|
||||
{
|
||||
private static readonly string[] ForbiddenTransportRegistrations =
|
||||
[
|
||||
"AddMessagingTransportClient",
|
||||
"AddTcpTransportClient",
|
||||
"AddUdpTransportClient",
|
||||
"AddRabbitMqTransportClient",
|
||||
"AddTlsTransportClient"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void WebServiceStartupPaths_MustNotDirectlyRegisterConcreteTransportClients()
|
||||
{
|
||||
var repoRoot = ResolveRepositoryRoot();
|
||||
var sourceRoot = Path.Combine(repoRoot, "src");
|
||||
|
||||
var startupFiles = Directory.EnumerateFiles(sourceRoot, "Program.cs", SearchOption.AllDirectories)
|
||||
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}__Tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
||||
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}examples{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in startupFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
foreach (var forbiddenMethod in ForbiddenTransportRegistrations)
|
||||
{
|
||||
if (!Regex.IsMatch(content, $@"\b{forbiddenMethod}\s*\(", RegexOptions.CultureInvariant))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(repoRoot, file).Replace('\\', '/');
|
||||
violations.Add($"{relativePath} uses {forbiddenMethod}()");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
private static string ResolveRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")) &&
|
||||
Directory.Exists(Path.Combine(current.FullName, "src")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root from test execution context.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.Messaging;
|
||||
using StellaOps.Router.Transport.Messaging.Options;
|
||||
using StellaOps.Router.Transport.Messaging.Protocol;
|
||||
using StellaOps.TestKit;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
public sealed class MessagingTransportQueueOptionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingTransportServer_StartAsync_UsesConfiguredConsumerGroup()
|
||||
{
|
||||
var options = Options.Create(new MessagingTransportOptions
|
||||
{
|
||||
ConsumerGroup = "router-gateway-test",
|
||||
BatchSize = 1
|
||||
});
|
||||
|
||||
var queueFactory = new RecordingQueueFactory();
|
||||
var server = new MessagingTransportServer(
|
||||
queueFactory,
|
||||
options,
|
||||
NullLogger<MessagingTransportServer>.Instance);
|
||||
|
||||
await server.StartAsync(CancellationToken.None);
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
|
||||
var requestQueue = queueFactory.CreatedQueues.Single(q =>
|
||||
q.MessageType == typeof(RpcRequestMessage) &&
|
||||
q.Options.QueueName == options.Value.GetGatewayControlQueueName());
|
||||
var responseQueue = queueFactory.CreatedQueues.Single(q =>
|
||||
q.MessageType == typeof(RpcResponseMessage) &&
|
||||
q.Options.QueueName == options.Value.ResponseQueueName);
|
||||
|
||||
requestQueue.Options.ConsumerGroup.Should().Be("router-gateway-test");
|
||||
responseQueue.Options.ConsumerGroup.Should().Be("router-gateway-test");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingTransportClient_ConnectAsync_UsesConfiguredConsumerGroup()
|
||||
{
|
||||
var options = Options.Create(new MessagingTransportOptions
|
||||
{
|
||||
ConsumerGroup = "timelineindexer-test",
|
||||
BatchSize = 1
|
||||
});
|
||||
|
||||
var queueFactory = new RecordingQueueFactory();
|
||||
var client = new MessagingTransportClient(
|
||||
queueFactory,
|
||||
options,
|
||||
NullLogger<MessagingTransportClient>.Instance);
|
||||
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "timelineindexer-1",
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(
|
||||
instance,
|
||||
[
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline"
|
||||
}
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
await client.DisconnectAsync();
|
||||
|
||||
queueFactory.CreatedQueues.Should().NotBeEmpty();
|
||||
queueFactory.CreatedQueues.Should().OnlyContain(q =>
|
||||
q.Options.ConsumerGroup == "timelineindexer-test");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingTransportClient_ConnectAsync_IncludesSchemasAndOpenApiInfoInHelloPayload()
|
||||
{
|
||||
var options = Options.Create(new MessagingTransportOptions
|
||||
{
|
||||
ConsumerGroup = "timelineindexer-test",
|
||||
BatchSize = 1
|
||||
});
|
||||
|
||||
var queueFactory = new RecordingQueueFactory();
|
||||
var client = new MessagingTransportClient(
|
||||
queueFactory,
|
||||
options,
|
||||
NullLogger<MessagingTransportClient>.Instance);
|
||||
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "timelineindexer-1",
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
};
|
||||
|
||||
var schemaId = "TimelineEvent";
|
||||
var schemas = new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
[schemaId] = new SchemaDefinition
|
||||
{
|
||||
SchemaId = schemaId,
|
||||
SchemaJson = "{\"type\":\"object\"}",
|
||||
ETag = "abc123"
|
||||
}
|
||||
};
|
||||
|
||||
await client.ConnectAsync(
|
||||
instance,
|
||||
[
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline"
|
||||
}
|
||||
],
|
||||
schemas,
|
||||
new ServiceOpenApiInfo
|
||||
{
|
||||
Title = "timelineindexer",
|
||||
Description = "Timeline service"
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
await client.DisconnectAsync();
|
||||
|
||||
var helloMessage = queueFactory.EnqueuedMessages
|
||||
.OfType<RpcRequestMessage>()
|
||||
.First(message => message.FrameType == Common.Enums.FrameType.Hello);
|
||||
|
||||
var payload = JsonSerializer.Deserialize<HelloPayload>(
|
||||
Convert.FromBase64String(helloMessage.PayloadBase64),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Schemas.Should().ContainKey(schemaId);
|
||||
payload.Schemas[schemaId].SchemaJson.Should().Be("{\"type\":\"object\"}");
|
||||
payload.OpenApiInfo.Should().NotBeNull();
|
||||
payload.OpenApiInfo!.Title.Should().Be("timelineindexer");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MessagingTransportOptions_DefaultControlQueue_DoesNotCollideWithGatewayServiceQueue()
|
||||
{
|
||||
var options = new MessagingTransportOptions();
|
||||
|
||||
var controlQueue = options.GetGatewayControlQueueName();
|
||||
var gatewayServiceQueue = options.GetRequestQueueName("gateway");
|
||||
|
||||
controlQueue.Should().NotBe(gatewayServiceQueue);
|
||||
controlQueue.Should().Be("router:requests:gateway-control");
|
||||
}
|
||||
|
||||
private sealed class RecordingQueueFactory : IMessageQueueFactory
|
||||
{
|
||||
public string ProviderName => "test";
|
||||
|
||||
public List<CreatedQueue> CreatedQueues { get; } = new();
|
||||
public List<object> EnqueuedMessages { get; } = new();
|
||||
|
||||
public IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options)
|
||||
where TMessage : class
|
||||
{
|
||||
CreatedQueues.Add(new CreatedQueue(typeof(TMessage), CloneOptions(options)));
|
||||
return new NoOpMessageQueue<TMessage>(options.QueueName, message => EnqueuedMessages.Add(message));
|
||||
}
|
||||
|
||||
private static MessageQueueOptions CloneOptions(MessageQueueOptions options)
|
||||
{
|
||||
return new MessageQueueOptions
|
||||
{
|
||||
QueueName = options.QueueName,
|
||||
ConsumerGroup = options.ConsumerGroup,
|
||||
ConsumerName = options.ConsumerName,
|
||||
DeadLetterQueue = options.DeadLetterQueue,
|
||||
DefaultLeaseDuration = options.DefaultLeaseDuration,
|
||||
MaxDeliveryAttempts = options.MaxDeliveryAttempts,
|
||||
IdempotencyWindow = options.IdempotencyWindow,
|
||||
ApproximateMaxLength = options.ApproximateMaxLength,
|
||||
RetryInitialBackoff = options.RetryInitialBackoff,
|
||||
RetryMaxBackoff = options.RetryMaxBackoff,
|
||||
RetryBackoffMultiplier = options.RetryBackoffMultiplier
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpMessageQueue<TMessage> : IMessageQueue<TMessage>
|
||||
where TMessage : class
|
||||
{
|
||||
private readonly Action<TMessage>? _onEnqueue;
|
||||
|
||||
public NoOpMessageQueue(string queueName, Action<TMessage>? onEnqueue = null)
|
||||
{
|
||||
QueueName = queueName;
|
||||
_onEnqueue = onEnqueue;
|
||||
}
|
||||
|
||||
public string ProviderName => "test";
|
||||
|
||||
public string QueueName { get; }
|
||||
|
||||
public ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_onEnqueue?.Invoke(message);
|
||||
return ValueTask.FromResult(EnqueueResult.Succeeded(Guid.NewGuid().ToString("N")));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>([]);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<IMessageLease<TMessage>>>([]);
|
||||
}
|
||||
|
||||
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CreatedQueue(Type MessageType, MessageQueueOptions Options);
|
||||
}
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| RVM-06 | DONE | Added messaging HELLO payload coverage to verify schema/openapi metadata propagation from microservice transport client. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuthorityClaimsRefreshServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_InitialRefresh_UpdatesClaimsStore()
|
||||
{
|
||||
var overrides = BuildOverrides("timeline.read");
|
||||
var provider = new FakeAuthorityClaimsProvider(overrides);
|
||||
var store = new RecordingClaimsStore();
|
||||
|
||||
using var service = CreateService(provider, store, usePushNotifications: false);
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
store.AuthorityUpdateCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PushWithUnchangedOverrides_DoesNotUpdateClaimsStoreTwice()
|
||||
{
|
||||
var overrides = BuildOverrides("timeline.read");
|
||||
var provider = new FakeAuthorityClaimsProvider(overrides);
|
||||
var store = new RecordingClaimsStore();
|
||||
|
||||
using var service = CreateService(provider, store, usePushNotifications: true);
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
|
||||
|
||||
provider.RaiseOverridesChanged(overrides);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
store.AuthorityUpdateCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PushWithChangedOverrides_UpdatesClaimsStoreAgain()
|
||||
{
|
||||
var initialOverrides = BuildOverrides("timeline.read");
|
||||
var changedOverrides = BuildOverrides("timeline.write");
|
||||
var provider = new FakeAuthorityClaimsProvider(initialOverrides);
|
||||
var store = new RecordingClaimsStore();
|
||||
|
||||
using var service = CreateService(provider, store, usePushNotifications: true);
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await WaitForAsync(() => store.AuthorityUpdateCount >= 1);
|
||||
|
||||
provider.RaiseOverridesChanged(changedOverrides);
|
||||
await WaitForAsync(() => store.AuthorityUpdateCount >= 2);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
store.AuthorityUpdateCount.Should().Be(2);
|
||||
}
|
||||
|
||||
private static AuthorityClaimsRefreshService CreateService(
|
||||
IAuthorityClaimsProvider provider,
|
||||
IEffectiveClaimsStore store,
|
||||
bool usePushNotifications)
|
||||
{
|
||||
return new AuthorityClaimsRefreshService(
|
||||
provider,
|
||||
store,
|
||||
Options.Create(new AuthorityConnectionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
AuthorityUrl = "http://authority.stella-ops.local",
|
||||
RefreshInterval = TimeSpan.FromHours(1),
|
||||
WaitForAuthorityOnStartup = false,
|
||||
UseAuthorityPushNotifications = usePushNotifications
|
||||
}),
|
||||
NullLogger<AuthorityClaimsRefreshService>.Instance);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(2);
|
||||
var started = DateTime.UtcNow;
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow - started > timeout)
|
||||
{
|
||||
throw new TimeoutException("Condition was not satisfied within timeout.");
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> BuildOverrides(string scope)
|
||||
{
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[EndpointKey.Create("timelineindexer", "GET", "/api/v1/timeline")] =
|
||||
[
|
||||
new ClaimRequirement
|
||||
{
|
||||
Type = "scope",
|
||||
Value = scope
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeAuthorityClaimsProvider : IAuthorityClaimsProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _initialOverrides;
|
||||
|
||||
public FakeAuthorityClaimsProvider(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> initialOverrides)
|
||||
{
|
||||
_initialOverrides = initialOverrides;
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public event EventHandler<ClaimsOverrideChangedEventArgs>? OverridesChanged;
|
||||
|
||||
public Task<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>> GetOverridesAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_initialOverrides);
|
||||
}
|
||||
|
||||
public void RaiseOverridesChanged(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
OverridesChanged?.Invoke(this, new ClaimsOverrideChangedEventArgs
|
||||
{
|
||||
Overrides = overrides
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingClaimsStore : IEffectiveClaimsStore
|
||||
{
|
||||
public int AuthorityUpdateCount { get; private set; }
|
||||
|
||||
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
}
|
||||
|
||||
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
AuthorityUpdateCount++;
|
||||
}
|
||||
|
||||
public void RemoveService(string serviceName)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.Middleware;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EndpointResolutionMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invoke_UsesTranslatedRequestPath_WhenPresent()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/advisory-ai/adapters";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/v1/advisory-ai/adapters";
|
||||
|
||||
var resolvedEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/v1/advisory-ai/adapters"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.ResolveEndpoint(HttpMethods.Get, "/v1/advisory-ai/adapters"))
|
||||
.Returns(resolvedEndpoint);
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new EndpointResolutionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(resolvedEndpoint);
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(HttpMethods.Get, "/v1/advisory-ai/adapters"),
|
||||
Times.Once);
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(HttpMethods.Get, "/api/v1/advisory-ai/adapters"),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_UsesRouteTargetMicroserviceHint_WhenPresent()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/advisoryai";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "advisoryai";
|
||||
|
||||
var signerRoot = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "signer",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/"
|
||||
};
|
||||
|
||||
var advisoryRoot = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-signer", "signer", signerRoot),
|
||||
CreateConnection("conn-advisory", "advisoryai", advisoryRoot)
|
||||
]);
|
||||
|
||||
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(advisoryRoot);
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("advisoryai");
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_DoesNotFallbackToDifferentService_WhenTargetHintHasNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/v1/timeline/events";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/timeline/events";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "timelineindexer";
|
||||
|
||||
var policyEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "policy-gateway",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/api/v1/timeline/events"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-policy", "policy-gateway", policyEndpoint)
|
||||
]);
|
||||
|
||||
routingState
|
||||
.Setup(state => state.ResolveEndpoint(HttpMethods.Get, "/api/v1/timeline/events"))
|
||||
.Returns(policyEndpoint);
|
||||
|
||||
var nextCalled = false;
|
||||
var middleware = new EndpointResolutionMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
routingState.Verify(
|
||||
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_MatchesRouteHintWithServicePrefixAlias()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/findingsLedger";
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/";
|
||||
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "findings";
|
||||
|
||||
var findingsRoot = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "findings-ledger",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Get,
|
||||
Path = "/"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns(
|
||||
[
|
||||
CreateConnection("conn-findings", "findings-ledger", findingsRoot)
|
||||
]);
|
||||
|
||||
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, routingState.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
|
||||
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("findings-ledger");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId,
|
||||
string serviceName,
|
||||
EndpointDescriptor endpoint)
|
||||
{
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = connectionId,
|
||||
ServiceName = serviceName,
|
||||
Version = endpoint.Version,
|
||||
Region = "local"
|
||||
};
|
||||
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = instance,
|
||||
TransportType = StellaOps.Router.Common.Enums.TransportType.Messaging
|
||||
};
|
||||
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.Middleware;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TransportDispatchMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invoke_UsesTranslatedRequestPath_WhenBuildingTransportFrame()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Post;
|
||||
context.Request.Path = "/api/v1/advisory-ai/adapters/openai/chat/completions";
|
||||
context.Request.QueryString = new QueryString("?model=gpt-4o-mini");
|
||||
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"prompt\":\"hello\"}"));
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/v1/advisory-ai/adapters/openai/chat/completions";
|
||||
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = HttpMethods.Post,
|
||||
Path = "/v1/advisory-ai/adapters/openai/chat/completions"
|
||||
};
|
||||
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "advisoryai-1",
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
TransportType = TransportType.Messaging
|
||||
};
|
||||
|
||||
var decision = new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
Connection = connection,
|
||||
TransportType = TransportType.Messaging,
|
||||
EffectiveTimeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
|
||||
|
||||
Frame? sentFrame = null;
|
||||
var transportClient = new Mock<ITransportClient>();
|
||||
transportClient
|
||||
.Setup(client => client.SendRequestAsync(
|
||||
connection,
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((_, frame, _, _) =>
|
||||
{
|
||||
sentFrame = frame;
|
||||
})
|
||||
.ReturnsAsync(() => FrameConverter.ToFrame(new ResponseFrame
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
StatusCode = StatusCodes.Status200OK,
|
||||
Payload = Encoding.UTF8.GetBytes("{\"ok\":true}")
|
||||
}));
|
||||
|
||||
transportClient
|
||||
.Setup(client => client.SendCancelAsync(connection, It.IsAny<Guid>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
transportClient
|
||||
.Setup(client => client.SendStreamingAsync(
|
||||
connection,
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.UpdateConnection(connection.ConnectionId, It.IsAny<Action<ConnectionState>>()))
|
||||
.Callback<string, Action<ConnectionState>>((_, update) => update(connection));
|
||||
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(env => env.EnvironmentName).Returns(Environments.Production);
|
||||
|
||||
var middleware = new TransportDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
NullLogger<TransportDispatchMiddleware>.Instance,
|
||||
environment.Object);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, transportClient.Object, routingState.Object);
|
||||
|
||||
// Assert
|
||||
sentFrame.Should().NotBeNull();
|
||||
var requestFrame = FrameConverter.ToRequestFrame(sentFrame!);
|
||||
requestFrame.Should().NotBeNull();
|
||||
requestFrame!.Path.Should().Be("/v1/advisory-ai/adapters/openai/chat/completions?model=gpt-4o-mini");
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Authorization;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.OpenApi;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpenApiDocumentGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateDocument_ProjectsPaths_ToGatewayMicroserviceRoutes()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/v1/advisory-ai/adapters",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Summary = "List AI adapters",
|
||||
Description = "Returns available advisory AI adapters."
|
||||
}
|
||||
};
|
||||
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "advisoryai-1",
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
TransportType = TransportType.Messaging
|
||||
};
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns([connection]);
|
||||
|
||||
var routeCatalog = new GatewayRouteCatalog(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/advisory-ai/adapters",
|
||||
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
|
||||
}
|
||||
]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog);
|
||||
|
||||
// Act
|
||||
var documentJson = generator.GenerateDocument();
|
||||
var document = JsonNode.Parse(documentJson)!.AsObject();
|
||||
var paths = document["paths"]!.AsObject();
|
||||
|
||||
// Assert
|
||||
paths.ContainsKey("/api/v1/advisory-ai/adapters").Should().BeTrue();
|
||||
paths.ContainsKey("/v1/advisory-ai/adapters").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_DoesNotCrossProjectRootRoutes_AcrossServices()
|
||||
{
|
||||
// Arrange
|
||||
var advisoryEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/v1/advisory-ai/adapters"
|
||||
};
|
||||
|
||||
var platformEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "platform",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/admin/tenants"
|
||||
};
|
||||
|
||||
var advisoryConnection = CreateConnection("advisoryai", advisoryEndpoint);
|
||||
var platformConnection = CreateConnection("platform", platformEndpoint);
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns([advisoryConnection, platformConnection]);
|
||||
|
||||
var routeCatalog = new GatewayRouteCatalog(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/advisoryai",
|
||||
TranslatesTo = "http://advisoryai.stella-ops.local"
|
||||
},
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/platform",
|
||||
TranslatesTo = "http://platform.stella-ops.local"
|
||||
}
|
||||
]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog);
|
||||
|
||||
// Act
|
||||
var documentJson = generator.GenerateDocument();
|
||||
var document = JsonNode.Parse(documentJson)!.AsObject();
|
||||
var paths = document["paths"]!.AsObject();
|
||||
|
||||
// Assert
|
||||
paths.ContainsKey("/advisoryai/v1/advisory-ai/adapters").Should().BeTrue();
|
||||
paths.ContainsKey("/platform/api/admin/tenants").Should().BeTrue();
|
||||
paths.ContainsKey("/advisoryai/api/admin/tenants").Should().BeFalse();
|
||||
paths.ContainsKey("/platform/v1/advisory-ai/adapters").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_UsesRoutePathServiceKey_WhenTranslatesToHostIsGeneric()
|
||||
{
|
||||
// Arrange
|
||||
var vulnEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "vulnexplorer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/vuln-explorer/search"
|
||||
};
|
||||
|
||||
var advisoryEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "advisoryai",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/v1/advisory-ai/adapters"
|
||||
};
|
||||
|
||||
var vulnConnection = CreateConnection("vulnexplorer", vulnEndpoint);
|
||||
var advisoryConnection = CreateConnection("advisoryai", advisoryEndpoint);
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns([vulnConnection, advisoryConnection]);
|
||||
|
||||
var routeCatalog = new GatewayRouteCatalog(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/vulnexplorer",
|
||||
TranslatesTo = "http://api.stella-ops.local"
|
||||
}
|
||||
]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog);
|
||||
|
||||
// Act
|
||||
var documentJson = generator.GenerateDocument();
|
||||
var document = JsonNode.Parse(documentJson)!.AsObject();
|
||||
var paths = document["paths"]!.AsObject();
|
||||
|
||||
// Assert
|
||||
paths.ContainsKey("/vulnexplorer/api/vuln-explorer/search").Should().BeTrue();
|
||||
paths.ContainsKey("/vulnexplorer/v1/advisory-ai/adapters").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_SkipsUnmappedEndpoints_WhenRouteCatalogIsProvided()
|
||||
{
|
||||
// Arrange
|
||||
var graphEndpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "graph",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/graph/diff"
|
||||
};
|
||||
|
||||
var graphConnection = CreateConnection("graph", graphEndpoint);
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState
|
||||
.Setup(state => state.GetAllConnections())
|
||||
.Returns([graphConnection]);
|
||||
|
||||
var routeCatalog = new GatewayRouteCatalog(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.Microservice,
|
||||
Path = "/api/v1/advisory-ai/adapters",
|
||||
TranslatesTo = "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters"
|
||||
}
|
||||
]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog);
|
||||
|
||||
// Act
|
||||
var documentJson = generator.GenerateDocument();
|
||||
var document = JsonNode.Parse(documentJson)!.AsObject();
|
||||
var paths = document["paths"]!.AsObject();
|
||||
|
||||
// Assert
|
||||
paths.ContainsKey("/graph/diff").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_AuthOnlyEndpoint_EmitsBearerSecurityAndGatewayAuthMetadata()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline",
|
||||
AllowAnonymous = false,
|
||||
RequiresAuthentication = true,
|
||||
AuthorizationSource = EndpointAuthorizationSource.AspNetMetadata
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
|
||||
operation["security"].Should().NotBeNull();
|
||||
operation["x-stellaops-gateway-auth"]!["requiresAuthentication"]!.GetValue<bool>().Should().BeTrue();
|
||||
operation["x-stellaops-gateway-auth"]!["allowAnonymous"]!.GetValue<bool>().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_LegacyEndpointWithoutAuthFlag_FailsClosedInAuthMetadata()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "notifier",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/v2/incidents/{deliveryId}/ack",
|
||||
AllowAnonymous = false,
|
||||
RequiresAuthentication = false,
|
||||
AuthorizationSource = EndpointAuthorizationSource.None
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("notifier", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v2/incidents/{deliveryId}/ack"]!["post"]!.AsObject();
|
||||
|
||||
operation["security"].Should().NotBeNull();
|
||||
operation["x-stellaops-gateway-auth"]!["allowAnonymous"]!.GetValue<bool>().Should().BeFalse();
|
||||
operation["x-stellaops-gateway-auth"]!["requiresAuthentication"]!.GetValue<bool>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_EmitsTimeoutExtensionWithEffectiveSeconds()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(90)
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog: null,
|
||||
routingOptions: Options.Create(new RoutingOptions
|
||||
{
|
||||
RoutingTimeoutMs = 30000,
|
||||
GlobalTimeoutCapMs = 20000
|
||||
}));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
var timeout = operation["x-stellaops-timeout"]!.AsObject();
|
||||
|
||||
timeout["effectiveSeconds"]!.GetValue<int>().Should().Be(20);
|
||||
timeout["source"]!.GetValue<string>().Should().Be("endpointCapped");
|
||||
operation["x-stellaops-timeout-seconds"]!.GetValue<int>().Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_ScopedEndpoint_EmitsOAuth2ScopeRequirements()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/v1/timeline/events",
|
||||
AllowAnonymous = false,
|
||||
RequiresAuthentication = true,
|
||||
RequiringClaims =
|
||||
[
|
||||
new ClaimRequirement
|
||||
{
|
||||
Type = "scope",
|
||||
Value = "timeline.write"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline/events"]!["post"]!.AsObject();
|
||||
var securityArray = operation["security"]!.AsArray();
|
||||
var requirement = securityArray[0]!.AsObject();
|
||||
|
||||
requirement.ContainsKey("BearerAuth").Should().BeTrue();
|
||||
requirement["OAuth2"]!.AsArray().Select(node => node!.GetValue<string>())
|
||||
.Should().ContainSingle(scope => scope == "timeline.write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_AuthorityOverrideClaims_UsesEffectiveClaimsForSecurityAndExtensions()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline",
|
||||
AllowAnonymous = false,
|
||||
RequiresAuthentication = true,
|
||||
RequiringClaims =
|
||||
[
|
||||
new ClaimRequirement
|
||||
{
|
||||
Type = "scope",
|
||||
Value = "timeline.read"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var effectiveClaimsStore = new TestEffectiveClaimsStore();
|
||||
effectiveClaimsStore.UpdateFromAuthority(
|
||||
new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[EndpointKey.Create("timelineindexer", "GET", "/api/v1/timeline")] =
|
||||
[
|
||||
new ClaimRequirement
|
||||
{
|
||||
Type = "scope",
|
||||
Value = "timeline.override"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()),
|
||||
routeCatalog: null,
|
||||
routingOptions: null,
|
||||
effectiveClaimsStore);
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
var security = operation["security"]!.AsArray()[0]!.AsObject();
|
||||
var authExtension = operation["x-stellaops-gateway-auth"]!.AsObject();
|
||||
|
||||
security["OAuth2"]!.AsArray().Select(node => node!.GetValue<string>())
|
||||
.Should().ContainSingle(scope => scope == "timeline.override");
|
||||
authExtension["effectiveClaimSource"]!.GetValue<string>().Should().Be("AuthorityOverride");
|
||||
authExtension["claimRequirements"]!.AsArray()
|
||||
.Select(node => node!["value"]!.GetValue<string>())
|
||||
.Should().ContainSingle(scope => scope == "timeline.override");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_ResponseStatusCodeFromEndpointMetadata_PrefersEndpointStatus()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/v1/timeline/events",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
ResponseSchemaId = "TimelineEventCreated",
|
||||
ResponseStatusCode = 201
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection(
|
||||
"timelineindexer",
|
||||
endpoint,
|
||||
new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
["TimelineEventCreated"] = new()
|
||||
{
|
||||
SchemaId = "TimelineEventCreated",
|
||||
SchemaJson = "{\"type\":\"object\"}",
|
||||
ETag = "schema"
|
||||
}
|
||||
});
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([connection]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var responses = document["paths"]!["/api/v1/timeline/events"]!["post"]!["responses"]!.AsObject();
|
||||
|
||||
responses.ContainsKey("201").Should().BeTrue();
|
||||
responses.ContainsKey("200").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithoutExplicitDescription_UsesSummaryAsDescriptionFallback()
|
||||
{
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline"
|
||||
};
|
||||
|
||||
var routingState = new Mock<IGlobalRoutingState>();
|
||||
routingState.Setup(state => state.GetAllConnections()).Returns([CreateConnection("timelineindexer", endpoint)]);
|
||||
|
||||
var generator = new OpenApiDocumentGenerator(
|
||||
routingState.Object,
|
||||
Options.Create(new OpenApiAggregationOptions()));
|
||||
|
||||
var document = JsonNode.Parse(generator.GenerateDocument())!.AsObject();
|
||||
var operation = document["paths"]!["/api/v1/timeline"]!["get"]!.AsObject();
|
||||
|
||||
operation["summary"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
|
||||
operation["description"]!.GetValue<string>().Should().Be("GET /api/v1/timeline");
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string serviceName,
|
||||
EndpointDescriptor endpoint,
|
||||
IReadOnlyDictionary<string, SchemaDefinition>? schemas = null)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = $"conn-{serviceName}",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"{serviceName}-1",
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Region = "local"
|
||||
},
|
||||
TransportType = TransportType.Messaging,
|
||||
Schemas = schemas ?? new Dictionary<string, SchemaDefinition>()
|
||||
};
|
||||
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
return connection;
|
||||
}
|
||||
|
||||
private sealed class TestEffectiveClaimsStore : IEffectiveClaimsStore
|
||||
{
|
||||
private readonly Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> _authority = new();
|
||||
|
||||
public IReadOnlyList<ClaimRequirement> GetEffectiveClaims(string serviceName, string method, string path)
|
||||
{
|
||||
var key = EndpointKey.Create(serviceName, method, path);
|
||||
return _authority.TryGetValue(key, out var claims) ? claims : [];
|
||||
}
|
||||
|
||||
public void UpdateFromMicroservice(string serviceName, IReadOnlyList<EndpointDescriptor> endpoints)
|
||||
{
|
||||
// Not needed for this test fixture.
|
||||
}
|
||||
|
||||
public void UpdateFromAuthority(IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>> overrides)
|
||||
{
|
||||
_authority.Clear();
|
||||
foreach (var entry in overrides)
|
||||
{
|
||||
_authority[entry.Key] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveService(string serviceName)
|
||||
{
|
||||
// Not needed for this test fixture.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
|
||||
namespace StellaOps.Router.Gateway.Tests.Routing;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DefaultRoutingPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_EndpointTimeout_IsCappedByGlobalCap()
|
||||
{
|
||||
var endpoint = CreateEndpoint(TimeSpan.FromSeconds(90));
|
||||
var plugin = CreatePlugin(
|
||||
new RoutingOptions
|
||||
{
|
||||
RoutingTimeoutMs = 30000,
|
||||
GlobalTimeoutCapMs = 20000
|
||||
});
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateRoutingContext(endpoint, routeDefaultTimeout: TimeSpan.FromSeconds(10)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(decision);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), decision!.EffectiveTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_UsesRouteDefaultTimeout_WhenEndpointTimeoutMissing()
|
||||
{
|
||||
var endpoint = CreateEndpoint(TimeSpan.Zero);
|
||||
var plugin = CreatePlugin(
|
||||
new RoutingOptions
|
||||
{
|
||||
RoutingTimeoutMs = 30000,
|
||||
GlobalTimeoutCapMs = 120000
|
||||
});
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateRoutingContext(endpoint, routeDefaultTimeout: TimeSpan.FromSeconds(12)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(decision);
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), decision!.EffectiveTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_UsesGatewayRouteDefault_WhenNoEndpointOrRouteTimeout()
|
||||
{
|
||||
var endpoint = CreateEndpoint(TimeSpan.Zero);
|
||||
var plugin = CreatePlugin(
|
||||
new RoutingOptions
|
||||
{
|
||||
RoutingTimeoutMs = 25000,
|
||||
GlobalTimeoutCapMs = 120000
|
||||
});
|
||||
|
||||
var decision = await plugin.ChooseInstanceAsync(
|
||||
CreateRoutingContext(endpoint, routeDefaultTimeout: null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(decision);
|
||||
Assert.Equal(TimeSpan.FromSeconds(25), decision!.EffectiveTimeout);
|
||||
}
|
||||
|
||||
private static DefaultRoutingPlugin CreatePlugin(RoutingOptions options)
|
||||
{
|
||||
return new DefaultRoutingPlugin(
|
||||
Options.Create(options),
|
||||
Options.Create(new RouterNodeConfig
|
||||
{
|
||||
Region = "local",
|
||||
NodeId = "gateway-1",
|
||||
Environment = "dev"
|
||||
}));
|
||||
}
|
||||
|
||||
private static RoutingContext CreateRoutingContext(
|
||||
EndpointDescriptor endpoint,
|
||||
TimeSpan? routeDefaultTimeout)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = endpoint.Method,
|
||||
Path = endpoint.Path,
|
||||
Endpoint = endpoint,
|
||||
Headers = new Dictionary<string, string>(),
|
||||
AvailableConnections = [CreateConnection(endpoint)],
|
||||
GatewayRegion = "local",
|
||||
RouteDefaultTimeout = routeDefaultTimeout,
|
||||
CancellationToken = CancellationToken.None
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(EndpointDescriptor endpoint)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = "conn-1",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "svc-1",
|
||||
ServiceName = endpoint.ServiceName,
|
||||
Version = endpoint.Version,
|
||||
Region = "local"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.Messaging
|
||||
};
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(TimeSpan timeout)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "timelineindexer",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/timeline",
|
||||
DefaultTimeout = timeout
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user