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

@@ -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. |