Repair router frontdoor convergence and live route contracts

This commit is contained in:
master
2026-03-09 19:09:19 +02:00
parent 49d1c57597
commit bf937c9395
25 changed files with 740 additions and 61 deletions

View File

@@ -1,7 +1,8 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using System.Text.Json;
namespace StellaOps.Gateway.WebService.Authorization;
@@ -66,6 +67,8 @@ public sealed class AuthorizationMiddleware
return;
}
var resolvedScopes = ResolveGatewayScopes(context);
foreach (var required in effectiveClaims)
{
var userClaims = context.User.Claims;
@@ -78,13 +81,7 @@ public sealed class AuthorizationMiddleware
else if (string.Equals(required.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(required.Type, "scp", StringComparison.OrdinalIgnoreCase))
{
// Scope claims may be space-separated (RFC 6749 §3.3) or individual claims.
// Check both: exact match on individual claims, and contains-within-space-separated.
hasClaim = userClaims.Any(c =>
c.Type == required.Type &&
(c.Value == required.Value ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, required.Value, StringComparison.Ordinal))));
hasClaim = HasRequiredScope(userClaims, resolvedScopes, required.Value);
}
else
{
@@ -108,6 +105,34 @@ public sealed class AuthorizationMiddleware
await _next(context);
}
private static ISet<string>? ResolveGatewayScopes(HttpContext context)
{
return context.Items.TryGetValue(GatewayContextKeys.Scopes, out var scopesObj) &&
scopesObj is ISet<string> scopes &&
scopes.Count > 0
? scopes
: null;
}
private static bool HasRequiredScope(
IEnumerable<Claim> userClaims,
ISet<string>? resolvedScopes,
string requiredScope)
{
if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope))
{
return true;
}
// Scope claims may be space-separated (RFC 6749 section 3.3) or individual claims.
return userClaims.Any(c =>
(string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase)) &&
(c.Value == requiredScope ||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal))));
}
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;

View File

@@ -0,0 +1,73 @@
using System.Net;
namespace StellaOps.Gateway.WebService.Configuration;
/// <summary>
/// Resolves container listener URLs from ASP.NET Core's explicit URL and port environment variables.
/// </summary>
public static class ContainerFrontdoorBindingResolver
{
public static IReadOnlyList<Uri> ResolveConfiguredUrls(
string? serverUrls,
string? explicitUrls,
string? explicitHttpPorts,
string? explicitHttpsPorts)
{
var rawUrls = !string.IsNullOrWhiteSpace(serverUrls)
? SplitEntries(serverUrls)
: !string.IsNullOrWhiteSpace(explicitUrls)
? SplitEntries(explicitUrls)
: BuildUrlsFromPorts(explicitHttpPorts, explicitHttpsPorts);
return rawUrls
.Select(TryCreateListenUri)
.Where(static uri => uri is not null)
.Cast<Uri>()
.DistinctBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<string> BuildUrlsFromPorts(string? explicitHttpPorts, string? explicitHttpsPorts)
{
foreach (var port in SplitEntries(explicitHttpPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"http://0.0.0.0:{value}";
}
}
foreach (var port in SplitEntries(explicitHttpsPorts))
{
if (int.TryParse(port, out var value) && value > 0)
{
yield return $"https://0.0.0.0:{value}";
}
}
}
private static IEnumerable<string> SplitEntries(string? value)
{
return string.IsNullOrWhiteSpace(value)
? []
: value.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static Uri? TryCreateListenUri(string rawUrl)
{
var normalized = NormalizeWildcardHost(rawUrl);
return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ? uri : null;
}
private static string NormalizeWildcardHost(string rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return string.Empty;
}
return rawUrl
.Replace("://+", $"://{IPAddress.Any}", StringComparison.Ordinal)
.Replace("://*", $"://{IPAddress.Any}", StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Configuration;
internal static class GatewayHealthThresholdPolicy
{
private static readonly TimeSpan DefaultMessagingHeartbeatInterval = TimeSpan.FromSeconds(10);
internal static void ApplyMinimums(HealthOptions options, GatewayMessagingTransportOptions messaging)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(messaging);
var heartbeatInterval = GatewayValueParser.ParseDuration(
messaging.HeartbeatInterval,
DefaultMessagingHeartbeatInterval);
if (heartbeatInterval <= TimeSpan.Zero)
{
heartbeatInterval = DefaultMessagingHeartbeatInterval;
}
var minimumDegradedThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 2);
var minimumStaleThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 3);
if (options.DegradedThreshold < minimumDegradedThreshold)
{
options.DegradedThreshold = minimumDegradedThreshold;
}
if (options.StaleThreshold < minimumStaleThreshold)
{
options.StaleThreshold = minimumStaleThreshold;
}
}
}

View File

@@ -433,6 +433,14 @@ public sealed class IdentityHeaderPolicyMiddleware
scopes.Add("scheduler.runs.preview");
scopes.Add("scheduler.runs.manage");
}
// Legacy operator sessions still mint orch:quota and rely on the gateway to
// resolve the equivalent fine-grained quota scopes before frontdoor auth.
if (scopes.Contains("orch:quota"))
{
scopes.Add("quota.read");
scopes.Add("quota.admin");
}
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)

View File

@@ -371,6 +371,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging);
});
builder.Services.AddOptions<OpenApiAggregationOptions>()
@@ -413,19 +414,18 @@ static bool ShouldApplyStellaOpsLocalBinding()
static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder)
{
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty;
var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey),
Environment.GetEnvironmentVariable("ASPNETCORE_URLS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"),
Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"));
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
var defaultCert = LoadDefaultCertificate(context.Configuration);
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
foreach (var uri in currentUrls)
{
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
continue;
}
var address = ResolveListenAddress(uri.Host);
if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
@@ -537,4 +537,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Router Libraries (now in same module) -->
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />

View File

@@ -61,11 +61,12 @@
},
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "15s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
},
"Routes": [
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
@@ -79,9 +80,9 @@
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
{ "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true },

View File

@@ -705,12 +705,12 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
private sealed class TemplateMatcher
{
private readonly RoutePattern _pattern;
private readonly List<(string Segment, bool IsParameter, string? ParameterName)> _segments;
private readonly List<(string Segment, bool IsParameter, string? ParameterName, bool IsCatchAll)> _segments;
public TemplateMatcher(RoutePattern pattern)
{
_pattern = pattern;
_segments = new List<(string, bool, string?)>();
_segments = new List<(string, bool, string?, bool)>();
foreach (var segment in pattern.PathSegments)
{
@@ -719,10 +719,10 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
switch (segment.Parts[0])
{
case RoutePatternLiteralPart literal:
_segments.Add((literal.Content, false, null));
_segments.Add((literal.Content, false, null, false));
break;
case RoutePatternParameterPart param:
_segments.Add((param.Name, true, param.Name));
_segments.Add((param.Name, true, param.Name, param.IsCatchAll));
break;
}
}
@@ -735,7 +735,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
RoutePatternParameterPart par => $"{{{par.Name}}}",
_ => ""
}));
_segments.Add((combined, false, null));
_segments.Add((combined, false, null, false));
}
}
}
@@ -746,21 +746,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length != _segments.Count)
if (pathSegments.Length != _segments.Count)
{
// Only explicit catch-all parameters may consume extra path segments.
if (_segments.Count > 0 &&
_segments[^1].IsParameter &&
_segments[^1].IsCatchAll &&
pathSegments.Length >= _segments.Count - 1)
{
// Check for catch-all
if (_segments.Count > 0 &&
_segments[^1].IsParameter &&
pathSegments.Length >= _segments.Count - 1)
// Handle catch-all: remaining path goes into last parameter
for (var i = 0; i < _segments.Count - 1; i++)
{
// Handle catch-all: remaining path goes into last parameter
for (var i = 0; i < _segments.Count - 1; i++)
var (segment, isParam, paramName, _) = _segments[i];
if (isParam)
{
var (segment, isParam, paramName) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;
@@ -775,14 +776,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
return false;
}
for (var i = 0; i < _segments.Count; i++)
{
var (segment, isParam, paramName) = _segments[i];
for (var i = 0; i < _segments.Count; i++)
{
var (segment, isParam, paramName, _) = _segments[i];
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
if (isParam)
{
routeValues[paramName!] = pathSegments[i];
}
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
{
return false;

View File

@@ -157,11 +157,6 @@ public static class StellaRouterIntegrationHelper
throw new InvalidOperationException("Router is enabled but no gateway endpoints are configured.");
}
services.TryAddStellaRouter(
serviceName,
version,
routerOptions);
var configuredTransports = routerOptions.Gateways
.GroupBy(gateway => gateway.TransportType)
.Select(group => (TransportType: group.Key, Gateway: group.First()))
@@ -178,6 +173,18 @@ public static class StellaRouterIntegrationHelper
resolvedMessagingSection,
configuredTransports);
ApplyImplicitHeartbeatInterval(
routerOptions,
configuration,
transportConfiguration,
resolvedRouterOptionsSection,
configuredTransports);
services.TryAddStellaRouter(
serviceName,
version,
routerOptions);
var pluginLoader = CreateTransportPluginLoader(
transportConfiguration,
resolvedRouterOptionsSection);
@@ -371,6 +378,33 @@ public static class StellaRouterIntegrationHelper
.Build();
}
private static void ApplyImplicitHeartbeatInterval(
StellaRouterOptionsBase routerOptions,
IConfiguration configuration,
IConfiguration transportConfiguration,
string routerOptionsSection,
IEnumerable<(TransportType TransportType, StellaRouterGatewayOptionsBase Gateway)> configuredTransports)
{
if (HasExplicitHeartbeatInterval(configuration, routerOptionsSection) ||
!configuredTransports.Any(static transport => transport.TransportType == TransportType.Messaging))
{
return;
}
var resolvedMessagingSection = BuildResolvedTransportSection(ToTransportPluginName(TransportType.Messaging));
var fallback = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
var heartbeatInterval = ParseDuration(
transportConfiguration[$"{resolvedMessagingSection}:HeartbeatInterval"],
fallback);
if (heartbeatInterval <= TimeSpan.Zero)
{
return;
}
routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds));
}
private static void CopySectionValues(
IConfigurationSection section,
IDictionary<string, string?> destination,
@@ -487,6 +521,13 @@ public static class StellaRouterIntegrationHelper
}
}
private static bool HasExplicitHeartbeatInterval(
IConfiguration configuration,
string routerOptionsSection)
{
return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:HeartbeatIntervalSeconds"]);
}
private static RouterTransportPluginLoader CreateTransportPluginLoader(
IConfiguration configuration,
string routerOptionsSection)

View File

@@ -9,30 +9,58 @@ namespace StellaOps.Router.Transport.Messaging.Extensions;
/// </summary>
internal static class QueueWaitExtensions
{
/// <summary>
/// Default fallback timeout when the queue supports Pub/Sub notifications.
/// The wait returns early on notification; this is only a safety net.
/// </summary>
private static readonly TimeSpan NotifiableTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MinimumNotifiableTimeout = TimeSpan.FromSeconds(1);
private static readonly TimeSpan MaximumNotifiableTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Fallback delay for queues that do not implement <see cref="INotifiableQueue"/>.
/// </summary>
private static readonly TimeSpan PollingFallback = TimeSpan.FromSeconds(1);
/// <summary>
/// Resolves the safety-net timeout for notifiable queues.
/// The queue stays push-first; this timeout only covers missed notifications.
/// </summary>
internal static TimeSpan ResolveNotifiableTimeout(TimeSpan heartbeatInterval)
{
if (heartbeatInterval <= TimeSpan.Zero)
{
return MaximumNotifiableTimeout;
}
var derivedSeconds = Math.Max(
MinimumNotifiableTimeout.TotalSeconds,
Math.Floor(heartbeatInterval.TotalSeconds / 3d));
var derivedTimeout = TimeSpan.FromSeconds(derivedSeconds);
if (derivedTimeout > MaximumNotifiableTimeout)
{
return MaximumNotifiableTimeout;
}
return derivedTimeout;
}
/// <summary>
/// Waits for new messages to become available on the queue.
/// </summary>
public static Task WaitForMessagesAsync<TMessage>(
this IMessageQueue<TMessage> queue,
TimeSpan heartbeatInterval,
CancellationToken cancellationToken)
where TMessage : class
{
if (queue is INotifiableQueue notifiable)
{
return notifiable.WaitForNotificationAsync(NotifiableTimeout, cancellationToken);
return notifiable.WaitForNotificationAsync(ResolveNotifiableTimeout(heartbeatInterval), cancellationToken);
}
return Task.Delay(PollingFallback, cancellationToken);
}
public static Task WaitForMessagesAsync<TMessage>(
this IMessageQueue<TMessage> queue,
CancellationToken cancellationToken)
where TMessage : class
=> WaitForMessagesAsync(queue, TimeSpan.Zero, cancellationToken);
}

View File

@@ -206,7 +206,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _responseQueue.WaitForMessagesAsync(cancellationToken);
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -270,7 +270,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _serviceIncomingQueue.WaitForMessagesAsync(cancellationToken);
await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)

View File

@@ -173,7 +173,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _requestQueue.WaitForMessagesAsync(cancellationToken);
await _requestQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -217,7 +217,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
// Wait for Pub/Sub notification instead of busy-polling.
if (leases.Count == 0)
{
await _responseQueue.WaitForMessagesAsync(cancellationToken);
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)

View File

@@ -9,6 +9,10 @@
<RootNamespace>StellaOps.Router.Transport.Messaging</RootNamespace>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Gateway.WebService.Middleware;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway;
using Xunit;
@@ -232,6 +233,33 @@ public sealed class AuthorizationMiddlewareTests
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_ScopeRequirement_UsesResolvedGatewayScopes_WhenPresent()
{
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "orch:quota")
});
context.Items[GatewayContextKeys.Scopes] = new HashSet<string>(StringComparer.Ordinal)
{
"orch:quota",
"quota.read",
"quota.admin"
};
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "quota.read" },
new() { Type = "scope", Value = "orch:quota" }
});
await _middleware.InvokeAsync(context);
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
{

View File

@@ -0,0 +1,47 @@
using StellaOps.Gateway.WebService.Configuration;
namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class ContainerFrontdoorBindingResolverTests
{
[Fact]
public void ResolveConfiguredUrls_NormalizesWildcardHostsFromExplicitUrls()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: null,
explicitUrls: "http://+:80;http://*:8080",
explicitHttpPorts: null,
explicitHttpsPorts: null);
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
"http://0.0.0.0/",
"http://0.0.0.0:8080/");
}
[Fact]
public void ResolveConfiguredUrls_BuildsUrlsFromPortEnvironment()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: null,
explicitUrls: null,
explicitHttpPorts: "80,8080",
explicitHttpsPorts: "8443");
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
"http://0.0.0.0/",
"http://0.0.0.0:8080/",
"https://0.0.0.0:8443/");
}
[Fact]
public void ResolveConfiguredUrls_PrefersServerUrlsWhenProvided()
{
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
serverUrls: "http://localhost:9090",
explicitUrls: "http://+:80",
explicitHttpPorts: "8080",
explicitHttpsPorts: null);
urls.Select(static uri => uri.AbsoluteUri).Should().Equal("http://localhost:9090/");
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Gateway.Configuration;
namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class GatewayHealthThresholdPolicyTests
{
[Fact]
public void ApplyMinimums_RaisesThresholdsToHeartbeatContract()
{
var options = new HealthOptions
{
DegradedThreshold = TimeSpan.FromSeconds(15),
StaleThreshold = TimeSpan.FromSeconds(30),
CheckInterval = TimeSpan.FromSeconds(5)
};
var messaging = new GatewayMessagingTransportOptions
{
Enabled = true,
HeartbeatInterval = "10s"
};
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(20));
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void ApplyMinimums_PreservesHigherExplicitThresholds()
{
var options = new HealthOptions
{
DegradedThreshold = TimeSpan.FromSeconds(45),
StaleThreshold = TimeSpan.FromSeconds(90),
CheckInterval = TimeSpan.FromSeconds(5)
};
var messaging = new GatewayMessagingTransportOptions
{
Enabled = true,
HeartbeatInterval = "10s"
};
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(45));
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(90));
}
}

View File

@@ -307,6 +307,26 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
Assert.Contains("admin", scopes);
}
[Fact]
public async Task InvokeAsync_ExpandsLegacyQuotaScopeIntoResolvedQuotaScopes()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "orch:quota")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("orch:quota", scopes);
Assert.Contains("quota.read", scopes);
Assert.Contains("quota.admin", scopes);
}
[Fact]
public async Task InvokeAsync_ScopesAreSortedDeterministically()
{

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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. |
| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. |
| LIVE-ROUTER-012-T1 | DONE | 2026-03-09: Added quota compatibility regressions for coarse-scope expansion and authorization against resolved gateway scopes. |

View File

@@ -0,0 +1,65 @@
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Transport.Messaging.Extensions;
namespace StellaOps.Gateway.WebService.Tests.Transport;
public sealed class QueueWaitExtensionsTests
{
[Fact]
public async Task WaitForMessagesAsync_UsesHeartbeatDerivedTimeout_ForNotifiableQueues()
{
var queue = new RecordingNotifiableQueue();
await queue.WaitForMessagesAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
queue.LastTimeout.Should().Be(QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(10)));
}
[Theory]
[InlineData(10, 3)]
[InlineData(45, 5)]
[InlineData(1, 1)]
public void ResolveNotifiableTimeout_ClampsToExpectedBounds(int heartbeatSeconds, int expectedSeconds)
{
var timeout = QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(heartbeatSeconds));
timeout.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
}
private sealed class RecordingNotifiableQueue : IMessageQueue<TestMessage>, INotifiableQueue
{
public string ProviderName => "test";
public string QueueName => "test-queue";
public TimeSpan? LastTimeout { get; private set; }
public ValueTask<EnqueueResult> EnqueueAsync(
TestMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task WaitForNotificationAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
LastTimeout = timeout;
return Task.CompletedTask;
}
}
private sealed record TestMessage(string Value);
}

View File

@@ -281,6 +281,92 @@ public sealed class AspNetRouterRequestDispatcherTests
Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal);
}
[Fact]
public async Task DispatchAsync_DoesNotTreatTerminalRouteParameterAsImplicitCatchAll()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapGet(
"/api/v1/notify/channels/{channelId}",
(string channelId) => Guid.TryParse(channelId, out _)
? Results.Ok(new { route = "detail", channelId })
: Results.BadRequest(new { error = "channelId must be a GUID." }));
app.MapGet(
"/api/v1/notify/channels/{channelId}/health",
(string channelId) => Guid.TryParse(channelId, out _)
? Results.Ok(new { route = "health", channelId })
: Results.BadRequest(new { error = "channelId must be a GUID." }));
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
var endpointDataSource = new StaticEndpointDataSource(
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
var dispatcher = new AspNetRouterRequestDispatcher(
app.Services,
endpointDataSource,
new StellaRouterBridgeOptions
{
ServiceName = "notify",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-notify-health-1",
Method = "GET",
Path = "/api/v1/notify/channels/e0000001-0000-0000-0000-000000000003/health",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
});
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Contains("\"route\":\"health\"", responseBody, StringComparison.Ordinal);
Assert.Contains("\"channelId\":\"e0000001-0000-0000-0000-000000000003\"", responseBody, StringComparison.Ordinal);
}
[Fact]
public async Task DispatchAsync_ExplicitCatchAllRouteStillConsumesRemainingSegments()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapGet("/api/v1/files/{**path}", (string path) => Results.Ok(new { path }));
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
var endpointDataSource = new StaticEndpointDataSource(
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
var dispatcher = new AspNetRouterRequestDispatcher(
app.Services,
endpointDataSource,
new StellaRouterBridgeOptions
{
ServiceName = "files",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var response = await dispatcher.DispatchAsync(new RequestFrame
{
RequestId = "req-files-catchall-1",
Method = "GET",
Path = "/api/v1/files/a/b/c.txt",
Headers = new Dictionary<string, string>(),
Payload = ReadOnlyMemory<byte>.Empty
});
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Contains("\"path\":\"a/b/c.txt\"", responseBody, StringComparison.Ordinal);
}
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
{
var services = new ServiceCollection();

View File

@@ -216,11 +216,13 @@ public sealed class StellaRouterIntegrationHelperTests
routerOptionsSection: "TimelineIndexer:Router");
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
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(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
Assert.Equal("router:responses", messaging.ResponseQueueName);
Assert.Equal("timelineindexer", messaging.ConsumerGroup);

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| 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. |
| HDR-CASE-01 | DONE | Added the lowercase `content-type` request-frame regression so JSON body dispatch survives Router frame round-trips. |
| LIVE-ROUTER-012-T2 | DONE | 2026-03-09: Added ASP.NET bridge regressions to prevent implicit catch-all matching for terminal route parameters while preserving explicit `{**path}` behavior. |