Repair router frontdoor convergence and live route contracts
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user