up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -1,270 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorityClaimsRefreshService"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClaimsRefreshServiceTests
|
||||
{
|
||||
private readonly Mock<IAuthorityClaimsProvider> _claimsProviderMock;
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public AuthorityClaimsRefreshServiceTests()
|
||||
{
|
||||
_claimsProviderMock = new Mock<IAuthorityClaimsProvider>();
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local",
|
||||
Enabled = true,
|
||||
RefreshInterval = TimeSpan.FromMilliseconds(100),
|
||||
WaitForAuthorityOnStartup = false,
|
||||
StartupTimeout = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
}
|
||||
|
||||
private AuthorityClaimsRefreshService CreateService()
|
||||
{
|
||||
return new AuthorityClaimsRefreshService(
|
||||
_claimsProviderMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<AuthorityClaimsRefreshService>.Instance);
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests - Disabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenDisabled_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoAuthorityUrl_DoesNotFetchClaims()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await service.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Enabled
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenEnabled_FetchesClaims()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.Verify(
|
||||
p => p.GetOverridesAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UpdatesStoreWithOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(overrides);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_claimsStoreMock.Verify(
|
||||
s => s.UpdateFromAuthority(It.Is<IReadOnlyDictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>>(
|
||||
d => d.ContainsKey(key))),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExecuteAsync Tests - Wait for Authority
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_FetchesOnStartup()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
// Authority is immediately available
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(true);
|
||||
|
||||
var fetchCalled = false;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.Callback(() => fetchCalled = true)
|
||||
.ReturnsAsync(new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>());
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - fetch was called during startup
|
||||
fetchCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WaitForAuthority_StopsAfterTimeout()
|
||||
{
|
||||
// Arrange
|
||||
_options.WaitForAuthorityOnStartup = true;
|
||||
_options.StartupTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
_claimsProviderMock.Setup(p => p.IsAvailable).Returns(false);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - should not block forever
|
||||
var startTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(300);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should complete even if Authority never becomes available
|
||||
startTask.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Push Notification Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithPushNotifications_SubscribesToEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - verify event subscription by checking it doesn't throw
|
||||
_claimsProviderMock.VerifyAdd(
|
||||
p => p.OverridesChanged += It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_WithPushNotifications_UnsubscribesFromEvent()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseAuthorityPushNotifications = true;
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Act
|
||||
await cts.CancelAsync();
|
||||
service.Dispose();
|
||||
|
||||
// Assert
|
||||
_claimsProviderMock.VerifyRemove(
|
||||
p => p.OverridesChanged -= It.IsAny<EventHandler<ClaimsOverrideChangedEventArgs>>(),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ProviderThrows_ContinuesRefreshLoop()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
_claimsProviderMock.Setup(p => p.GetOverridesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
throw new HttpRequestException("Test error");
|
||||
}
|
||||
return new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>();
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(250); // Wait for at least 2 refresh cycles
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should have continued after error
|
||||
callCount.Should().BeGreaterThan(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuthorizationMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IEffectiveClaimsStore> _claimsStoreMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public AuthorizationMiddlewareTests()
|
||||
{
|
||||
_claimsStoreMock = new Mock<IEffectiveClaimsStore>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private AuthorizationMiddleware CreateMiddleware()
|
||||
{
|
||||
return new AuthorizationMiddleware(
|
||||
_nextMock.Object,
|
||||
_claimsStoreMock.Object,
|
||||
NullLogger<AuthorizationMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ClaimsPrincipal? user = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
context.User = user;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
ClaimRequirement[]? claims = null)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path,
|
||||
RequiringClaims = claims ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithClaims(params (string Type, string Value)[] claims)
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
claims.Select(c => new Claim(c.Type, c.Value)),
|
||||
"TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
#region No Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithEmptyRequiringClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>());
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Matching Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithClaimTypeOnly_MatchesAnyValue()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "any-value"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = null } // Any value matches
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMultipleMatchingClaims_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(
|
||||
("role", "admin"),
|
||||
("department", "engineering"),
|
||||
("level", "senior"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing Claims Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "user")); // Has role, but wrong value
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaimType_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("department", "engineering"));
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNoClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(); // No claims at all
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithPartialMatchingClaims_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims(("role", "admin")); // Has one, missing another
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" },
|
||||
new() { Type = "department", Value = "engineering" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Body Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMissingClaim_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var user = CreateUserWithClaims();
|
||||
var context = CreateHttpContext(endpoint: endpoint, user: user);
|
||||
|
||||
_claimsStoreMock.Setup(s => s.GetEffectiveClaims(
|
||||
endpoint.ServiceName, endpoint.Method, endpoint.Path))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "role", Value = "admin" }
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
// Assert
|
||||
context.Response.ContentType.Should().StartWith("application/json");
|
||||
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Forbidden");
|
||||
responseBody.Should().Contain("role");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class CancellationTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelReasons_HasAllExpectedConstants()
|
||||
{
|
||||
Assert.Equal("ClientDisconnected", CancelReasons.ClientDisconnected);
|
||||
Assert.Equal("Timeout", CancelReasons.Timeout);
|
||||
Assert.Equal("PayloadLimitExceeded", CancelReasons.PayloadLimitExceeded);
|
||||
Assert.Equal("Shutdown", CancelReasons.Shutdown);
|
||||
Assert.Equal("ConnectionClosed", CancelReasons.ConnectionClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_RegistersWithRegistry()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
// Act
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var connectionIdField = client.GetType()
|
||||
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var connectionId = connectionIdField?.GetValue(client)?.ToString();
|
||||
Assert.NotNull(connectionId);
|
||||
|
||||
var channel = _registry.GetChannel(connectionId!);
|
||||
Assert.NotNull(channel);
|
||||
Assert.Equal(instance.InstanceId, channel!.Instance?.InstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAllInflight_DoesNotThrowWhenEmpty()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
client.CancelAllInflight(CancelReasons.Shutdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateClient();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_CancelsAllInflightWithShutdownReason()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await client.DisconnectAsync();
|
||||
|
||||
// Assert - no exception means success
|
||||
}
|
||||
}
|
||||
|
||||
public class InflightRequestTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Track_ReturnsCancellationToken()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var token = tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(token.IsCancellationRequested);
|
||||
Assert.Equal(1, tracker.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_ThrowsIfAlreadyTracked()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
tracker.Track(correlationId);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => tracker.Track(correlationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_TriggersCancellationToken()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
var token = tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = tracker.Cancel(correlationId, "TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(token.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_ReturnsFalseForUnknownRequest()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = tracker.Cancel(correlationId, "TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_RemovesFromTracking()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
var correlationId = Guid.NewGuid();
|
||||
tracker.Track(correlationId);
|
||||
Assert.Equal(1, tracker.Count);
|
||||
|
||||
// Act
|
||||
tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, tracker.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAll_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
using var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
var tokens = new List<CancellationToken>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
tokens.Add(tracker.Track(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// Act
|
||||
tracker.CancelAll("TestReason");
|
||||
|
||||
// Assert
|
||||
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
var tracker = new InflightRequestTracker(
|
||||
NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
var tokens = new List<CancellationToken>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
tokens.Add(tracker.Track(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// Act
|
||||
tracker.Dispose();
|
||||
|
||||
// Assert
|
||||
Assert.All(tokens, t => Assert.True(t.IsCancellationRequested));
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration-style tests for <see cref="ConnectionManager"/>.
|
||||
/// Uses real InMemoryTransportServer since it's a sealed class.
|
||||
/// </summary>
|
||||
public sealed class ConnectionManagerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _connectionRegistry;
|
||||
private readonly InMemoryTransportServer _transportServer;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly ConnectionManager _manager;
|
||||
|
||||
public ConnectionManagerTests()
|
||||
{
|
||||
_connectionRegistry = new InMemoryConnectionRegistry();
|
||||
|
||||
var options = Options.Create(new InMemoryTransportOptions());
|
||||
_transportServer = new InMemoryTransportServer(
|
||||
_connectionRegistry,
|
||||
options,
|
||||
NullLogger<InMemoryTransportServer>.Instance);
|
||||
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
|
||||
|
||||
_manager = new ConnectionManager(
|
||||
_transportServer,
|
||||
_connectionRegistry,
|
||||
_routingStateMock.Object,
|
||||
NullLogger<ConnectionManager>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _manager.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _manager.StopAsync(CancellationToken.None);
|
||||
_transportServer.Dispose();
|
||||
}
|
||||
|
||||
#region StartAsync/StopAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ShouldStartSuccessfully()
|
||||
{
|
||||
// The manager starts in InitializeAsync
|
||||
// Just verify it can be started without exception
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_ShouldStopSuccessfully()
|
||||
{
|
||||
// This is tested in DisposeAsync
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Registration Tests via Channel Simulation
|
||||
|
||||
[Fact]
|
||||
public async Task WhenHelloReceived_AddsConnectionToRoutingState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// Simulate sending a HELLO frame through the channel
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
// Act
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
|
||||
// Give time for the frame to be processed
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenHeartbeatReceived_UpdatesConnectionState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// First send HELLO to register the connection
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - send heartbeat
|
||||
var heartbeatFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(heartbeatFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenConnectionClosed_RemovesConnectionFromRoutingState()
|
||||
{
|
||||
// Arrange
|
||||
var channel = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
|
||||
// First send HELLO to register the connection
|
||||
var helloFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
};
|
||||
await channel.ToGateway.Writer.WriteAsync(helloFrame);
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - close the channel
|
||||
await channel.LifetimeToken.CancelAsync();
|
||||
|
||||
// Give time for the close to be processed
|
||||
await Task.Delay(200);
|
||||
|
||||
// Assert - may be called multiple times (on close and on stop)
|
||||
_routingStateMock.Verify(
|
||||
s => s.RemoveConnection("conn-1"),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenMultipleConnectionsRegister_AllAreTracked()
|
||||
{
|
||||
// Arrange
|
||||
var channel1 = CreateAndRegisterChannel("conn-1", "service-a", "1.0.0");
|
||||
var channel2 = CreateAndRegisterChannel("conn-2", "service-b", "2.0.0");
|
||||
|
||||
// Act - send HELLO frames
|
||||
await channel1.ToGateway.Writer.WriteAsync(new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
});
|
||||
await channel2.ToGateway.Writer.WriteAsync(new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString()
|
||||
});
|
||||
await Task.Delay(150);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-1")),
|
||||
Times.Once);
|
||||
_routingStateMock.Verify(
|
||||
s => s.AddConnection(It.Is<ConnectionState>(c => c.ConnectionId == "conn-2")),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private InMemoryChannel CreateAndRegisterChannel(
|
||||
string connectionId, string serviceName, string version)
|
||||
{
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
// Create channel through the registry
|
||||
var channel = _connectionRegistry.CreateChannel(connectionId);
|
||||
channel.Instance = instance;
|
||||
|
||||
// Simulate that the transport server is listening to this connection
|
||||
_transportServer.StartListeningToConnection(connectionId);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class DefaultRoutingPluginTests
|
||||
{
|
||||
private readonly RoutingOptions _options = new()
|
||||
{
|
||||
DefaultVersion = null,
|
||||
StrictVersionMatching = true,
|
||||
RoutingTimeoutMs = 30000,
|
||||
PreferLocalRegion = true,
|
||||
AllowDegradedInstances = true,
|
||||
TieBreaker = TieBreakerMode.Random,
|
||||
PingToleranceMs = 0.1
|
||||
};
|
||||
|
||||
private readonly GatewayNodeConfig _gatewayConfig = new()
|
||||
{
|
||||
Region = "us-east-1",
|
||||
NodeId = "gw-test-01",
|
||||
Environment = "test",
|
||||
NeighborRegions = ["eu-west-1", "us-west-2"]
|
||||
};
|
||||
|
||||
private DefaultRoutingPlugin CreateSut(
|
||||
Action<RoutingOptions>? configureOptions = null,
|
||||
Action<GatewayNodeConfig>? configureGateway = null)
|
||||
{
|
||||
configureOptions?.Invoke(_options);
|
||||
configureGateway?.Invoke(_gatewayConfig);
|
||||
return new DefaultRoutingPlugin(
|
||||
Options.Create(_options),
|
||||
Options.Create(_gatewayConfig));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "us-east-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
double averagePingMs = 0,
|
||||
DateTime? lastHeartbeatUtc = null)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory,
|
||||
AveragePingMs = averagePingMs,
|
||||
LastHeartbeatUtc = lastHeartbeatUtc ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingContext CreateContext(
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
string gatewayRegion = "us-east-1",
|
||||
string? requestedVersion = null,
|
||||
EndpointDescriptor? endpoint = null,
|
||||
params ConnectionState[] connections)
|
||||
{
|
||||
return new RoutingContext
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
GatewayRegion = gatewayRegion,
|
||||
RequestedVersion = requestedVersion,
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
AvailableConnections = connections
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoConnections()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection();
|
||||
var context = new RoutingContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
GatewayRegion = "us-east-1",
|
||||
Endpoint = null,
|
||||
AvailableConnections = [connection]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSelectHealthyConnection()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection(status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Should().BeSameAs(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferHealthyOverDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var degraded = CreateConnection("conn-1", status: InstanceHealthStatus.Degraded);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [degraded, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Status.Should().Be(InstanceHealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSelectDegraded_WhenNoHealthyAndAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = true);
|
||||
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
|
||||
var context = CreateContext(connections: [degraded]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenOnlyDegradedAndNotAllowed()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.AllowDegradedInstances = false);
|
||||
var degraded = CreateConnection(status: InstanceHealthStatus.Degraded);
|
||||
var context = CreateContext(connections: [degraded]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldExcludeUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var unhealthy = CreateConnection("conn-1", status: InstanceHealthStatus.Unhealthy);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [unhealthy, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldExcludeDraining()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var draining = CreateConnection("conn-1", status: InstanceHealthStatus.Draining);
|
||||
var healthy = CreateConnection("conn-2", status: InstanceHealthStatus.Healthy);
|
||||
var context = CreateContext(connections: [draining, healthy]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldFilterByRequestedVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Version.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldUseDefaultVersion_WhenNoRequestedVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.DefaultVersion = "1.0.0");
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnNull_WhenNoMatchingVersion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var context = CreateContext(requestedVersion: "2.0.0", connections: [v1]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldMatchAnyVersion_WhenNoVersionSpecified()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.DefaultVersion = null);
|
||||
var v1 = CreateConnection("conn-1", version: "1.0.0");
|
||||
var v2 = CreateConnection("conn-2", version: "2.0.0");
|
||||
var context = CreateContext(requestedVersion: null, connections: [v1, v2]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferLocalRegion()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2");
|
||||
var local = CreateConnection("conn-2", region: "us-east-1");
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldAllowRemoteRegion_WhenNoLocalAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = true);
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2");
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("us-west-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldIgnoreRegionPreference_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.PreferLocalRegion = false);
|
||||
// Create connections with same ping and heartbeat so they are tied
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var remote = CreateConnection("conn-1", region: "us-west-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var local = CreateConnection("conn-2", region: "us-east-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remote, local]);
|
||||
|
||||
// Act - run multiple times to verify random selection includes both
|
||||
var selectedRegions = new HashSet<string>();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
selectedRegions.Add(result!.Connection.Instance.Region);
|
||||
}
|
||||
|
||||
// Assert - with random selection, we should see both regions selected
|
||||
// Note: This is probabilistic but should almost always pass
|
||||
selectedRegions.Should().Contain("us-west-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSetCorrectTimeout()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.RoutingTimeoutMs = 5000);
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.EffectiveTimeout.Should().Be(TimeSpan.FromMilliseconds(5000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldSetCorrectTransportType()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.TransportType.Should().Be(TransportType.InMemory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldReturnEndpointFromContext()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var endpoint = CreateEndpoint(path: "/api/special");
|
||||
var connection = CreateConnection();
|
||||
var context = CreateContext(endpoint: endpoint, connections: [connection]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Endpoint.Path.Should().Be("/api/special");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldDistributeLoadAcrossMultipleConnections()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
// Create connections with same ping and heartbeat so they are tied
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn3 = CreateConnection("conn-3", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [conn1, conn2, conn3]);
|
||||
|
||||
// Act - run multiple times
|
||||
var selectedConnections = new Dictionary<string, int>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
var connId = result!.Connection.ConnectionId;
|
||||
selectedConnections[connId] = selectedConnections.GetValueOrDefault(connId) + 1;
|
||||
}
|
||||
|
||||
// Assert - all connections should be selected at least once (probabilistic with random tie-breaker)
|
||||
selectedConnections.Should().HaveCount(3);
|
||||
selectedConnections.Keys.Should().Contain("conn-1");
|
||||
selectedConnections.Keys.Should().Contain("conn-2");
|
||||
selectedConnections.Keys.Should().Contain("conn-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferLowerPing()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var highPing = CreateConnection("conn-1", averagePingMs: 100, lastHeartbeatUtc: sameHeartbeat);
|
||||
var lowPing = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [highPing, lowPing]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - lower ping should be preferred
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferMoreRecentHeartbeat_WhenPingEqual()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut();
|
||||
var now = DateTime.UtcNow;
|
||||
var oldHeartbeat = CreateConnection("conn-1", averagePingMs: 10, lastHeartbeatUtc: now.AddSeconds(-30));
|
||||
var recentHeartbeat = CreateConnection("conn-2", averagePingMs: 10, lastHeartbeatUtc: now);
|
||||
var context = CreateContext(connections: [oldHeartbeat, recentHeartbeat]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - more recent heartbeat should be preferred
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldPreferNeighborRegionOverRemote()
|
||||
{
|
||||
// Arrange - gateway config has NeighborRegions = ["eu-west-1", "us-west-2"]
|
||||
var sut = CreateSut();
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var remoteRegion = CreateConnection("conn-1", region: "ap-south-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var neighborRegion = CreateConnection("conn-2", region: "eu-west-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(gatewayRegion: "us-east-1", connections: [remoteRegion, neighborRegion]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - neighbor region should be preferred over remote
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.Instance.Region.Should().Be("eu-west-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldUseRoundRobin_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o => o.TieBreaker = TieBreakerMode.RoundRobin);
|
||||
var sameHeartbeat = DateTime.UtcNow;
|
||||
var conn1 = CreateConnection("conn-1", lastHeartbeatUtc: sameHeartbeat);
|
||||
var conn2 = CreateConnection("conn-2", lastHeartbeatUtc: sameHeartbeat);
|
||||
var context = CreateContext(connections: [conn1, conn2]);
|
||||
|
||||
// Act - with round-robin, we should cycle through connections
|
||||
var selections = new List<string>();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
selections.Add(result!.Connection.ConnectionId);
|
||||
}
|
||||
|
||||
// Assert - should alternate between connections
|
||||
selections.Distinct().Count().Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_ShouldCombineFilters()
|
||||
{
|
||||
// Arrange
|
||||
var sut = CreateSut(configureOptions: o =>
|
||||
{
|
||||
o.PreferLocalRegion = true;
|
||||
o.AllowDegradedInstances = false;
|
||||
});
|
||||
|
||||
// Create various combinations
|
||||
var wrongVersionHealthyLocal = CreateConnection("conn-1", version: "2.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
|
||||
var rightVersionDegradedLocal = CreateConnection("conn-2", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Degraded);
|
||||
var rightVersionHealthyRemote = CreateConnection("conn-3", version: "1.0.0", region: "us-west-2", status: InstanceHealthStatus.Healthy);
|
||||
var rightVersionHealthyLocal = CreateConnection("conn-4", version: "1.0.0", region: "us-east-1", status: InstanceHealthStatus.Healthy);
|
||||
|
||||
var context = CreateContext(
|
||||
gatewayRegion: "us-east-1",
|
||||
requestedVersion: "1.0.0",
|
||||
connections: [wrongVersionHealthyLocal, rightVersionDegradedLocal, rightVersionHealthyRemote, rightVersionHealthyLocal]);
|
||||
|
||||
// Act
|
||||
var result = await sut.ChooseInstanceAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - should select the only connection matching all criteria
|
||||
result.Should().NotBeNull();
|
||||
result!.Connection.ConnectionId.Should().Be("conn-4");
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EffectiveClaimsStore"/>.
|
||||
/// </summary>
|
||||
public sealed class EffectiveClaimsStoreTests
|
||||
{
|
||||
private readonly EffectiveClaimsStore _store;
|
||||
|
||||
public EffectiveClaimsStoreTests()
|
||||
{
|
||||
_store = new EffectiveClaimsStore(NullLogger<EffectiveClaimsStore>.Instance);
|
||||
}
|
||||
|
||||
#region GetEffectiveClaims Tests
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_NoClaimsRegistered_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange - fresh store
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("service", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
claims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MicroserviceClaimsOnly_ReturnsMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Type.Should().Be("role");
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_AuthorityOverridesTakePrecedence()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
var key = EndpointKey.Create("test-service", "GET", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
claims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_MethodNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "get",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveClaims_PathNormalization_MatchesCaseInsensitively()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/API/USERS",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
claims.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromMicroservice Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_MultipleEndpoints_RegistersAll()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "reader" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("test-service", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_EmptyClaims_RemovesFromStore()
|
||||
{
|
||||
// Arrange - first add some claims
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints1);
|
||||
|
||||
// Now update with empty claims
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromMicroservice_DefaultEmptyClaims_TreatedAsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users"
|
||||
// RequiringClaims defaults to []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateFromAuthority Tests
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_ClearsPreviousOverrides()
|
||||
{
|
||||
// Arrange - add initial override
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/test1");
|
||||
var overrides1 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "old" }]
|
||||
};
|
||||
_store.UpdateFromAuthority(overrides1);
|
||||
|
||||
// Update with new overrides (different key)
|
||||
var key2 = EndpointKey.Create("service2", "POST", "/api/test2");
|
||||
var overrides2 = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "new" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides2);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/test1").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service2", "POST", "/api/test2").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_EmptyClaimsNotStored()
|
||||
{
|
||||
// Arrange
|
||||
var key = EndpointKey.Create("service", "GET", "/api/test");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key] = []
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert - should fall back to microservice (which is empty)
|
||||
_store.GetEffectiveClaims("service", "GET", "/api/test").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromAuthority_MultipleOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var key1 = EndpointKey.Create("service1", "GET", "/api/users");
|
||||
var key2 = EndpointKey.Create("service1", "POST", "/api/users");
|
||||
var overrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
|
||||
{
|
||||
[key1] = [new ClaimRequirement { Type = "role", Value = "reader" }],
|
||||
[key2] = [new ClaimRequirement { Type = "role", Value = "writer" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
_store.UpdateFromAuthority(overrides);
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service1", "GET", "/api/users")[0].Value.Should().Be("reader");
|
||||
_store.GetEffectiveClaims("service1", "POST", "/api/users")[0].Value.Should().Be("writer");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveService Tests
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_RemovesMicroserviceClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("test-service", endpoints);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("test-service");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_CaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "Test-Service",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("Test-Service", endpoints);
|
||||
|
||||
// Act - remove with different case
|
||||
_store.RemoveService("TEST-SERVICE");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("test-service", "GET", "/api/users").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_OnlyRemovesTargetService()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints1 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-a",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/a",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "a" }]
|
||||
}
|
||||
};
|
||||
var endpoints2 = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "service-b",
|
||||
Version = "1.0.0",
|
||||
Method = "GET",
|
||||
Path = "/api/b",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "b" }]
|
||||
}
|
||||
};
|
||||
_store.UpdateFromMicroservice("service-a", endpoints1);
|
||||
_store.UpdateFromMicroservice("service-b", endpoints2);
|
||||
|
||||
// Act
|
||||
_store.RemoveService("service-a");
|
||||
|
||||
// Assert
|
||||
_store.GetEffectiveClaims("service-a", "GET", "/api/a").Should().BeEmpty();
|
||||
_store.GetEffectiveClaims("service-b", "GET", "/api/b").Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveService_UnknownService_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _store.RemoveService("unknown-service");
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointResolutionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointResolutionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public EndpointResolutionMiddlewareTests()
|
||||
{
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private EndpointResolutionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new EndpointResolutionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(string method = "GET", string path = "/api/test")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string method = "GET",
|
||||
string path = "/api/test")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
#region Matching Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_SetsHttpContextItem()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithMatchingEndpoint_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext();
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknown Path Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUnknownPath_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "/api/unknown");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/unknown"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("not found");
|
||||
responseBody.Should().Contain("/api/unknown");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP Method Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithPostMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "POST");
|
||||
var context = CreateHttpContext(method: "POST");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("POST", "/api/test"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithDeleteMethod_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(method: "DELETE", path: "/api/users/123");
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithWrongMethod_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(method: "DELETE", path: "/api/test");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("DELETE", "/api/test"))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Variations Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithParameterizedPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/api/users/{id}");
|
||||
var context = CreateHttpContext(path: "/api/users/123");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users/123"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRootPath_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(path: "/");
|
||||
var context = CreateHttpContext(path: "/");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/"))
|
||||
.Returns(endpoint);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithEmptyPath_PassesEmptyStringToRouting()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(path: "");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", ""))
|
||||
.Returns((EndpointDescriptor?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.ResolveEndpoint("GET", ""), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Calls Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_MultipleCalls_EachResolvesIndependently()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint1 = CreateEndpoint(path: "/api/users");
|
||||
var endpoint2 = CreateEndpoint(path: "/api/items");
|
||||
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/users"))
|
||||
.Returns(endpoint1);
|
||||
_routingStateMock.Setup(r => r.ResolveEndpoint("GET", "/api/items"))
|
||||
.Returns(endpoint2);
|
||||
|
||||
var context1 = CreateHttpContext(path: "/api/users");
|
||||
var context2 = CreateHttpContext(path: "/api/items");
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _routingStateMock.Object);
|
||||
await middleware.Invoke(context2, _routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context1.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint1);
|
||||
context2.Items[RouterHttpContextKeys.EndpointDescriptor].Should().Be(endpoint2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HealthMonitorService"/>.
|
||||
/// </summary>
|
||||
public sealed class HealthMonitorServiceTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly HealthOptions _options;
|
||||
|
||||
public HealthMonitorServiceTests()
|
||||
{
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>(MockBehavior.Loose);
|
||||
_options = new HealthOptions
|
||||
{
|
||||
StaleThreshold = TimeSpan.FromSeconds(10),
|
||||
DegradedThreshold = TimeSpan.FromSeconds(5),
|
||||
CheckInterval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
}
|
||||
|
||||
private HealthMonitorService CreateService()
|
||||
{
|
||||
return new HealthMonitorService(
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_options),
|
||||
NullLogger<HealthMonitorService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_MarksStaleConnectionsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var staleConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
staleConnection.Status = InstanceHealthStatus.Healthy;
|
||||
staleConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15); // Past stale threshold
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([staleConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_MarksDegradedConnectionsDegraded()
|
||||
{
|
||||
// Arrange
|
||||
var degradedConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
degradedConnection.Status = InstanceHealthStatus.Healthy;
|
||||
degradedConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-7); // Past degraded but not stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([degradedConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
// Wait enough time for at least one check cycle (CheckInterval is 100ms)
|
||||
await Task.Delay(300, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotChangeHealthyConnections()
|
||||
{
|
||||
// Arrange
|
||||
var healthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
healthyConnection.Status = InstanceHealthStatus.Healthy;
|
||||
healthyConnection.LastHeartbeatUtc = DateTime.UtcNow; // Fresh heartbeat
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([healthyConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - should not have updated the connection
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotChangeDrainingConnections()
|
||||
{
|
||||
// Arrange
|
||||
var drainingConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
drainingConnection.Status = InstanceHealthStatus.Draining;
|
||||
drainingConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([drainingConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - draining connections should be left alone
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DoesNotDoubleMarkUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var unhealthyConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
unhealthyConnection.Status = InstanceHealthStatus.Unhealthy;
|
||||
unhealthyConnection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-30); // Very stale
|
||||
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([unhealthyConnection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert - already unhealthy connections should not be updated
|
||||
_routingStateMock.Verify(
|
||||
s => s.UpdateConnection(It.IsAny<string>(), It.IsAny<Action<ConnectionState>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAction_SetsStatusToUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
connection.Status = InstanceHealthStatus.Healthy;
|
||||
connection.LastHeartbeatUtc = DateTime.UtcNow.AddSeconds(-15);
|
||||
|
||||
Action<ConnectionState>? capturedAction = null;
|
||||
_routingStateMock.Setup(s => s.UpdateConnection("conn-1", It.IsAny<Action<ConnectionState>>()))
|
||||
.Callback<string, Action<ConnectionState>>((id, action) => capturedAction = action);
|
||||
_routingStateMock.Setup(s => s.GetAllConnections())
|
||||
.Returns([connection]);
|
||||
|
||||
var service = CreateService();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act - run the service briefly
|
||||
try
|
||||
{
|
||||
await service.StartAsync(cts.Token);
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
capturedAction.Should().NotBeNull();
|
||||
|
||||
// Apply the action to verify it sets Unhealthy
|
||||
var testConnection = CreateConnection("conn-1", "service-a", "1.0.0");
|
||||
testConnection.Status = InstanceHealthStatus.Healthy;
|
||||
capturedAction!(testConnection);
|
||||
|
||||
testConnection.Status.Should().Be(InstanceHealthStatus.Unhealthy);
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId, string serviceName, string version)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"{serviceName}-{Guid.NewGuid():N}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
LastHeartbeatUtc = DateTime.UtcNow,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HttpAuthorityClaimsProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpAuthorityClaimsProviderTests
|
||||
{
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthorityConnectionOptions _options;
|
||||
|
||||
public HttpAuthorityClaimsProviderTests()
|
||||
{
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
_options = new AuthorityConnectionOptions
|
||||
{
|
||||
AuthorityUrl = "http://authority.local"
|
||||
};
|
||||
}
|
||||
|
||||
private HttpAuthorityClaimsProvider CreateProvider()
|
||||
{
|
||||
return new HttpAuthorityClaimsProvider(
|
||||
_httpClient,
|
||||
Options.Create(_options),
|
||||
NullLogger<HttpAuthorityClaimsProvider>.Instance);
|
||||
}
|
||||
|
||||
#region GetOverridesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NoAuthorityUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = string.Empty;
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_WhitespaceUrl_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = " ";
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_SuccessfulResponse_ParsesOverrides()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "test-service",
|
||||
method = "GET",
|
||||
path = "/api/users",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "admin" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
|
||||
var key = result.Keys.First();
|
||||
key.ServiceName.Should().Be("test-service");
|
||||
key.Method.Should().Be("GET");
|
||||
key.Path.Should().Be("/api/users");
|
||||
|
||||
result[key].Should().HaveCount(1);
|
||||
result[key][0].Type.Should().Be("role");
|
||||
result[key][0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_EmptyOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = Array.Empty<object>()
|
||||
});
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NullOverrides_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = "{}";
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_HttpError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.InternalServerError, "Error");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_Timeout_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new TaskCanceledException("Timeout"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_NetworkError_ReturnsEmptyAndSetsUnavailable()
|
||||
{
|
||||
// Arrange
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_TrimsTrailingSlash()
|
||||
{
|
||||
// Arrange
|
||||
_options.AuthorityUrl = "http://authority.local/";
|
||||
var responseBody = JsonSerializer.Serialize(new { overrides = Array.Empty<object>() });
|
||||
|
||||
string? capturedUrl = null;
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
|
||||
{
|
||||
capturedUrl = req.RequestUri?.ToString();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody)
|
||||
};
|
||||
});
|
||||
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
capturedUrl.Should().Be("http://authority.local/api/v1/claims/overrides");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOverridesAsync_MultipleOverrides_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var responseBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
overrides = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
serviceName = "service-a",
|
||||
method = "GET",
|
||||
path = "/api/a",
|
||||
requiringClaims = new[] { new { type = "role", value = "a" } }
|
||||
},
|
||||
new
|
||||
{
|
||||
serviceName = "service-b",
|
||||
method = "POST",
|
||||
path = "/api/b",
|
||||
requiringClaims = new[]
|
||||
{
|
||||
new { type = "role", value = "b1" },
|
||||
new { type = "department", value = "b2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
SetupHttpResponse(HttpStatusCode.OK, responseBody);
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsAvailable Tests
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_InitiallyFalse()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_TrueAfterSuccessfulFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.OK, "{}");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAvailable_FalseAfterFailedFetch()
|
||||
{
|
||||
// Arrange
|
||||
SetupHttpResponse(HttpStatusCode.ServiceUnavailable, "");
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.GetOverridesAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
provider.IsAvailable.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OverridesChanged Event Tests
|
||||
|
||||
[Fact]
|
||||
public void OverridesChanged_CanBeSubscribed()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
var eventRaised = false;
|
||||
|
||||
// Act
|
||||
provider.OverridesChanged += (_, _) => eventRaised = true;
|
||||
|
||||
// Assert - no exception during subscription, event not raised yet
|
||||
eventRaised.Should().BeFalse();
|
||||
provider.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content)
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class InMemoryRoutingStateTests
|
||||
{
|
||||
private readonly InMemoryRoutingState _sut = new();
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "us-east-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
params (string Method, string Path)[] endpoints)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
|
||||
foreach (var (method, path) in endpoints)
|
||||
{
|
||||
connection.Endpoints[(method, path)] = new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConnection_ShouldStoreConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
|
||||
// Act
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeSameAs(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConnection_ShouldIndexEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}")]);
|
||||
|
||||
// Act
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/users/123");
|
||||
endpoint.Should().NotBeNull();
|
||||
endpoint!.Path.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldRemoveConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection(connection.ConnectionId);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldRemoveEndpointsWhenLastConnection()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection(connection.ConnectionId);
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
|
||||
endpoint.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_ShouldKeepEndpointsWhenOtherConnectionsExist()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
_sut.RemoveConnection("conn-1");
|
||||
|
||||
// Assert
|
||||
var endpoint = _sut.ResolveEndpoint("GET", "/api/test");
|
||||
endpoint.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConnection_ShouldApplyUpdate()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
_sut.UpdateConnection(connection.ConnectionId, c => c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection(connection.ConnectionId);
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(InstanceHealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConnection_ShouldDoNothingForUnknownConnection()
|
||||
{
|
||||
// Act - should not throw
|
||||
_sut.UpdateConnection("unknown", c => c.Status = InstanceHealthStatus.Degraded);
|
||||
|
||||
// Assert
|
||||
var result = _sut.GetConnection("unknown");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnection_ShouldReturnNullForUnknownConnection()
|
||||
{
|
||||
// Act
|
||||
var result = _sut.GetConnection("unknown");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ShouldReturnAllConnections()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", endpoints: [("GET", "/api/test1")]);
|
||||
var connection2 = CreateConnection("conn-2", endpoints: [("GET", "/api/test2")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(connection1);
|
||||
result.Should().Contain(connection2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ShouldReturnEmptyWhenNoConnections()
|
||||
{
|
||||
// Act
|
||||
var result = _sut.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldMatchExactPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/health")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/health");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Path.Should().Be("/api/health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldMatchParameterizedPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/users/{id}/orders/{orderId}")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/users/123/orders/456");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Path.Should().Be("/api/users/{id}/orders/{orderId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldReturnNullForNonMatchingMethod()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("POST", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldReturnNullForNonMatchingPath()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("GET", "/api/other");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpoint_ShouldBeCaseInsensitiveForMethod()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection(endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.ResolveEndpoint("get", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldFilterByServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", "service-b", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Instance.ServiceName.Should().Be("service-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldFilterByVersion()
|
||||
{
|
||||
// Arrange
|
||||
var connection1 = CreateConnection("conn-1", "service-a", "1.0.0", endpoints: [("GET", "/api/test")]);
|
||||
var connection2 = CreateConnection("conn-2", "service-a", "2.0.0", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection1);
|
||||
_sut.AddConnection(connection2);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Instance.Version.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldReturnEmptyWhenNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/test")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-b", "1.0.0", "GET", "/api/test");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_ShouldMatchParameterizedPaths()
|
||||
{
|
||||
// Arrange
|
||||
var connection = CreateConnection("conn-1", "service-a", endpoints: [("GET", "/api/users/{id}")]);
|
||||
_sut.AddConnection(connection);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetConnectionsFor("service-a", "1.0.0", "GET", "/api/users/123");
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class ClaimSecurityMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithNoEndpoints_ReturnsBearerAuthOnly()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().NotContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_WithClaimRequirements_ReturnsOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
schemes.Should().ContainKey("BearerAuth");
|
||||
schemes.Should().ContainKey("OAuth2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_CollectsAllUniqueScopes()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:read" }]
|
||||
},
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/payments",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }] // Duplicate
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var oauth2 = schemes["OAuth2"];
|
||||
var scopes = oauth2!["flows"]!["clientCredentials"]!["scopes"]!;
|
||||
|
||||
scopes.AsObject().Count.Should().Be(2); // Only unique scopes
|
||||
scopes["billing:write"].Should().NotBeNull();
|
||||
scopes["billing:read"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_SetsCorrectTokenUrl()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = new[]
|
||||
{
|
||||
new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "test:write" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/custom/token");
|
||||
|
||||
// Assert
|
||||
var tokenUrl = schemes["OAuth2"]!["flows"]!["clientCredentials"]!["tokenUrl"]!.GetValue<string>();
|
||||
tokenUrl.Should().Be("/custom/token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithNoClaimRequirements_ReturnsEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/public",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecurityRequirement_WithClaimRequirements_ReturnsBearerAndOAuth2()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/secure",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims =
|
||||
[
|
||||
new ClaimRequirement { Type = "billing:write" },
|
||||
new ClaimRequirement { Type = "billing:admin" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var requirement = ClaimSecurityMapper.GenerateSecurityRequirement(endpoint);
|
||||
|
||||
// Assert
|
||||
requirement.Count.Should().Be(1);
|
||||
|
||||
var req = requirement[0]!.AsObject();
|
||||
req.Should().ContainKey("BearerAuth");
|
||||
req.Should().ContainKey("OAuth2");
|
||||
|
||||
var scopes = req["OAuth2"]!.AsArray();
|
||||
scopes.Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateSecuritySchemes_BearerAuth_HasCorrectStructure()
|
||||
{
|
||||
// Arrange
|
||||
var endpoints = Array.Empty<EndpointDescriptor>();
|
||||
|
||||
// Act
|
||||
var schemes = ClaimSecurityMapper.GenerateSecuritySchemes(endpoints, "/auth/token");
|
||||
|
||||
// Assert
|
||||
var bearer = schemes["BearerAuth"]!.AsObject();
|
||||
bearer["type"]!.GetValue<string>().Should().Be("http");
|
||||
bearer["scheme"]!.GetValue<string>().Should().Be("bearer");
|
||||
bearer["bearerFormat"]!.GetValue<string>().Should().Be("JWT");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class GatewayOpenApiDocumentCacheTests
|
||||
{
|
||||
private readonly Mock<IOpenApiDocumentGenerator> _generator = new();
|
||||
private readonly OpenApiAggregationOptions _options = new() { CacheTtlSeconds = 60 };
|
||||
private readonly GatewayOpenApiDocumentCache _sut;
|
||||
|
||||
public GatewayOpenApiDocumentCacheTests()
|
||||
{
|
||||
_sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_FirstCall_GeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_SubsequentCalls_ReturnsCachedDocument()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (doc1, _, _) = _sut.GetDocument();
|
||||
var (doc2, _, _) = _sut.GetDocument();
|
||||
var (doc3, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
doc1.Should().Be(expectedDoc);
|
||||
doc2.Should().Be(expectedDoc);
|
||||
doc3.Should().Be(expectedDoc);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_AfterInvalidate_RegeneratesDocument()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (result1, _, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (result2, _, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(doc1);
|
||||
result2.Should().Be(doc2);
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsConsistentETag()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDoc = """{"openapi":"3.1.0"}""";
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns(expectedDoc);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBeNullOrEmpty();
|
||||
etag1.Should().Be(etag2);
|
||||
etag1.Should().StartWith("\"").And.EndWith("\""); // ETag format
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_DifferentContent_DifferentETag()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = """{"openapi":"3.1.0","version":"1"}""";
|
||||
var doc2 = """{"openapi":"3.1.0","version":"2"}""";
|
||||
|
||||
_generator.SetupSequence(x => x.GenerateDocument())
|
||||
.Returns(doc1)
|
||||
.Returns(doc2);
|
||||
|
||||
// Act
|
||||
var (_, etag1, _) = _sut.GetDocument();
|
||||
_sut.Invalidate();
|
||||
var (_, etag2, _) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
etag1.Should().NotBe(etag2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_ReturnsGenerationTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
var beforeGeneration = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var (_, _, generatedAt) = _sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
generatedAt.Should().BeOnOrAfter(beforeGeneration);
|
||||
generatedAt.Should().BeOnOrBefore(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
_generator.Setup(x => x.GenerateDocument()).Returns("{}");
|
||||
_sut.GetDocument();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
_sut.Invalidate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDocument_WithZeroTtl_AlwaysRegenerates()
|
||||
{
|
||||
// Arrange
|
||||
var options = new OpenApiAggregationOptions { CacheTtlSeconds = 0 };
|
||||
var sut = new GatewayOpenApiDocumentCache(
|
||||
_generator.Object,
|
||||
Options.Create(options));
|
||||
|
||||
var callCount = 0;
|
||||
_generator.Setup(x => x.GenerateDocument())
|
||||
.Returns(() => $"{{\"call\":{++callCount}}}");
|
||||
|
||||
// Act
|
||||
sut.GetDocument();
|
||||
// Wait a tiny bit to ensure TTL is exceeded
|
||||
Thread.Sleep(10);
|
||||
sut.GetDocument();
|
||||
|
||||
// Assert
|
||||
// With 0 TTL, each call should regenerate
|
||||
_generator.Verify(x => x.GenerateDocument(), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.OpenApi;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.OpenApi;
|
||||
|
||||
public class OpenApiDocumentGeneratorTests
|
||||
{
|
||||
private readonly Mock<IGlobalRoutingState> _routingState = new();
|
||||
private readonly OpenApiAggregationOptions _options = new();
|
||||
private readonly OpenApiDocumentGenerator _sut;
|
||||
|
||||
public OpenApiDocumentGeneratorTests()
|
||||
{
|
||||
_sut = new OpenApiDocumentGenerator(
|
||||
_routingState.Object,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
params EndpointDescriptor[] endpoints)
|
||||
{
|
||||
var connection = new ConnectionState
|
||||
{
|
||||
ConnectionId = $"conn-{serviceName}",
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{serviceName}",
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = InstanceHealthStatus.Healthy,
|
||||
TransportType = TransportType.InMemory,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>(),
|
||||
OpenApiInfo = new ServiceOpenApiInfo
|
||||
{
|
||||
Title = serviceName,
|
||||
Description = $"Test {serviceName} service"
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
connection.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithNoConnections_ReturnsValidOpenApiDocument()
|
||||
{
|
||||
// Arrange
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
document.Should().NotBeNullOrEmpty();
|
||||
|
||||
var doc = JsonDocument.Parse(document);
|
||||
doc.RootElement.GetProperty("openapi").GetString().Should().Be("3.1.0");
|
||||
doc.RootElement.GetProperty("info").GetProperty("title").GetString().Should().Be(_options.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_SetsCorrectInfoSection()
|
||||
{
|
||||
// Arrange
|
||||
_options.Title = "My Gateway API";
|
||||
_options.Description = "My description";
|
||||
_options.Version = "2.0.0";
|
||||
_options.LicenseName = "MIT";
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var info = doc.RootElement.GetProperty("info");
|
||||
|
||||
info.GetProperty("title").GetString().Should().Be("My Gateway API");
|
||||
info.GetProperty("description").GetString().Should().Be("My description");
|
||||
info.GetProperty("version").GetString().Should().Be("2.0.0");
|
||||
info.GetProperty("license").GetProperty("name").GetString().Should().Be("MIT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithConnections_GeneratesPaths()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var connection = CreateConnection("inventory", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var paths = doc.RootElement.GetProperty("paths");
|
||||
|
||||
paths.TryGetProperty("/api/items", out var pathItem).Should().BeTrue();
|
||||
pathItem.TryGetProperty("get", out var operation).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemaInfo_IncludesDocumentation()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Summary = "Create invoice",
|
||||
Description = "Creates a new invoice",
|
||||
Tags = ["billing", "invoices"],
|
||||
Deprecated = false
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.GetProperty("summary").GetString().Should().Be("Create invoice");
|
||||
operation.GetProperty("description").GetString().Should().Be("Creates a new invoice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithSchemas_IncludesSchemaReferences()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
RequestSchemaId = "CreateInvoiceRequest"
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
var connectionWithSchemas = new ConnectionState
|
||||
{
|
||||
ConnectionId = connection.ConnectionId,
|
||||
Instance = connection.Instance,
|
||||
Status = connection.Status,
|
||||
TransportType = connection.TransportType,
|
||||
Schemas = new Dictionary<string, SchemaDefinition>
|
||||
{
|
||||
["CreateInvoiceRequest"] = new SchemaDefinition
|
||||
{
|
||||
SchemaId = "CreateInvoiceRequest",
|
||||
SchemaJson = """{"type": "object", "properties": {"amount": {"type": "number"}}}""",
|
||||
ETag = "\"ABC123\""
|
||||
}
|
||||
}
|
||||
};
|
||||
connectionWithSchemas.Endpoints[(endpoint.Method, endpoint.Path)] = endpoint;
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connectionWithSchemas]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check request body reference
|
||||
var requestBody = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post")
|
||||
.GetProperty("requestBody")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema")
|
||||
.GetProperty("$ref")
|
||||
.GetString();
|
||||
|
||||
requestBody.Should().Be("#/components/schemas/billing_CreateInvoiceRequest");
|
||||
|
||||
// Check schema exists in components
|
||||
var schemas = doc.RootElement.GetProperty("components").GetProperty("schemas");
|
||||
schemas.TryGetProperty("billing_CreateInvoiceRequest", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithClaimRequirements_IncludesSecurity()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0",
|
||||
RequiringClaims = [new ClaimRequirement { Type = "billing:write" }]
|
||||
};
|
||||
|
||||
var connection = CreateConnection("billing", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
|
||||
// Check security schemes
|
||||
var securitySchemes = doc.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("securitySchemes");
|
||||
|
||||
securitySchemes.TryGetProperty("BearerAuth", out _).Should().BeTrue();
|
||||
securitySchemes.TryGetProperty("OAuth2", out _).Should().BeTrue();
|
||||
|
||||
// Check operation security
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/invoices")
|
||||
.GetProperty("post");
|
||||
|
||||
operation.TryGetProperty("security", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithMultipleServices_GeneratesTags()
|
||||
{
|
||||
// Arrange
|
||||
var billingEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/invoices",
|
||||
ServiceName = "billing",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
var inventoryEndpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/items",
|
||||
ServiceName = "inventory",
|
||||
Version = "2.0.0"
|
||||
};
|
||||
|
||||
var billingConn = CreateConnection("billing", "1.0.0", billingEndpoint);
|
||||
var inventoryConn = CreateConnection("inventory", "2.0.0", inventoryEndpoint);
|
||||
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([billingConn, inventoryConn]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var tags = doc.RootElement.GetProperty("tags");
|
||||
|
||||
tags.GetArrayLength().Should().Be(2);
|
||||
|
||||
var tagNames = new List<string>();
|
||||
foreach (var tag in tags.EnumerateArray())
|
||||
{
|
||||
tagNames.Add(tag.GetProperty("name").GetString()!);
|
||||
}
|
||||
|
||||
tagNames.Should().Contain("billing");
|
||||
tagNames.Should().Contain("inventory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDocument_WithDeprecatedEndpoint_SetsDeprecatedFlag()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointDescriptor
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/legacy",
|
||||
ServiceName = "test",
|
||||
Version = "1.0.0",
|
||||
SchemaInfo = new EndpointSchemaInfo
|
||||
{
|
||||
Deprecated = true
|
||||
}
|
||||
};
|
||||
|
||||
var connection = CreateConnection("test", "1.0.0", endpoint);
|
||||
_routingState.Setup(x => x.GetAllConnections()).Returns([connection]);
|
||||
|
||||
// Act
|
||||
var document = _sut.GenerateDocument();
|
||||
|
||||
// Assert
|
||||
var doc = JsonDocument.Parse(document);
|
||||
var operation = doc.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/legacy")
|
||||
.GetProperty("get");
|
||||
|
||||
operation.GetProperty("deprecated").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PayloadLimitsMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class PayloadLimitsMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IPayloadTracker> _trackerMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly PayloadLimits _defaultLimits;
|
||||
private bool _nextCalled;
|
||||
|
||||
public PayloadLimitsMiddlewareTests()
|
||||
{
|
||||
_trackerMock = new Mock<IPayloadTracker>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_defaultLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 10 * 1024 * 1024, // 10MB
|
||||
MaxRequestBytesPerConnection = 100 * 1024 * 1024, // 100MB
|
||||
MaxAggregateInflightBytes = 1024 * 1024 * 1024 // 1GB
|
||||
};
|
||||
}
|
||||
|
||||
private PayloadLimitsMiddleware CreateMiddleware(PayloadLimits? limits = null)
|
||||
{
|
||||
return new PayloadLimitsMiddleware(
|
||||
_nextMock.Object,
|
||||
Options.Create(limits ?? _defaultLimits),
|
||||
NullLogger<PayloadLimitsMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(long? contentLength = null, string connectionId = "conn-1")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Body = new MemoryStream();
|
||||
context.Connection.Id = connectionId;
|
||||
|
||||
if (contentLength.HasValue)
|
||||
{
|
||||
context.Request.ContentLength = contentLength;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
#region Within Limits Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithinLimits_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: null);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithZeroContentLength_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 0);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 0))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Call Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_Returns413()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerCallLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 2000);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Payload Too Large");
|
||||
responseBody.Should().Contain("1000");
|
||||
responseBody.Should().Contain("2000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExactlyAtPerCallLimit_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var limits = new PayloadLimits { MaxRequestBytesPerCall = 1000 };
|
||||
var middleware = CreateMiddleware(limits);
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Aggregate Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
_trackerMock.Setup(t => t.CurrentInflightBytes)
|
||||
.Returns(1024 * 1024 * 1024); // 1GB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsAggregateLimit_WritesOverloadedResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Overloaded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-Connection Limit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_Returns429()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false); // Not aggregate limit
|
||||
_trackerMock.Setup(t => t.GetConnectionInflightBytes("conn-1"))
|
||||
.Returns(100 * 1024 * 1024); // 100MB
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_ExceedsPerConnectionLimit_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(false);
|
||||
_trackerMock.Setup(t => t.IsOverloaded)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Too Many Requests");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterSuccess_ReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_AfterNextThrows_StillReleasesReservation()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(contentLength: 1000);
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve("conn-1", 1000))
|
||||
.Returns(true);
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Test error"));
|
||||
|
||||
// Act
|
||||
var act = async () => await middleware.Invoke(context, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
_trackerMock.Verify(t => t.Release("conn-1", It.IsAny<long>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Different Connections Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_DifferentConnections_TrackedSeparately()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context1 = CreateHttpContext(contentLength: 1000, connectionId: "conn-1");
|
||||
var context2 = CreateHttpContext(contentLength: 2000, connectionId: "conn-2");
|
||||
|
||||
_trackerMock.Setup(t => t.TryReserve(It.IsAny<string>(), It.IsAny<long>()))
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(context1, _trackerMock.Object);
|
||||
await middleware.Invoke(context2, _trackerMock.Object);
|
||||
|
||||
// Assert
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-1", 1000), Times.Once);
|
||||
_trackerMock.Verify(t => t.TryReserve("conn-2", 2000), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class PayloadTrackerTests
|
||||
{
|
||||
private readonly PayloadLimits _limits = new()
|
||||
{
|
||||
MaxRequestBytesPerCall = 1024,
|
||||
MaxRequestBytesPerConnection = 4096,
|
||||
MaxAggregateInflightBytes = 8192
|
||||
};
|
||||
|
||||
private PayloadTracker CreateTracker()
|
||||
{
|
||||
return new PayloadTracker(
|
||||
Options.Create(_limits),
|
||||
NullLogger<PayloadTracker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_WithinLimits_ReturnsTrue()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
var result = tracker.TryReserve("conn-1", 500);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(500, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_ExceedsAggregateLimits_ReturnsFalse()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reserve from multiple connections to approach aggregate limit (8192)
|
||||
// Each connection can have up to 4096 bytes
|
||||
Assert.True(tracker.TryReserve("conn-1", 4000));
|
||||
Assert.True(tracker.TryReserve("conn-2", 4000));
|
||||
// Now at 8000 bytes
|
||||
|
||||
// Another reservation that exceeds aggregate limit (8000 + 500 > 8192) should fail
|
||||
var result = tracker.TryReserve("conn-3", 500);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(8000, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_ExceedsPerConnectionLimit_ReturnsFalse()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reserve up to per-connection limit
|
||||
Assert.True(tracker.TryReserve("conn-1", 4000));
|
||||
|
||||
// Next reservation on same connection should fail
|
||||
var result = tracker.TryReserve("conn-1", 500);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserve_DifferentConnections_TrackedSeparately()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
Assert.True(tracker.TryReserve("conn-1", 3000));
|
||||
Assert.True(tracker.TryReserve("conn-2", 3000));
|
||||
|
||||
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
Assert.Equal(3000, tracker.GetConnectionInflightBytes("conn-2"));
|
||||
Assert.Equal(6000, tracker.CurrentInflightBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_DecreasesInflightBytes()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 1000);
|
||||
tracker.Release("conn-1", 500);
|
||||
|
||||
Assert.Equal(500, tracker.CurrentInflightBytes);
|
||||
Assert.Equal(500, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_CannotGoNegative()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 100);
|
||||
tracker.Release("conn-1", 500); // More than reserved
|
||||
|
||||
Assert.Equal(0, tracker.GetConnectionInflightBytes("conn-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOverloaded_TrueWhenExceedsLimit()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// Reservation at limit passes (8192 <= 8192 is false for >, so not overloaded at exactly limit)
|
||||
// But we can't exceed the limit. The IsOverloaded check is for current > limit
|
||||
// So at exactly 8192, IsOverloaded should be false (8192 > 8192 is false)
|
||||
// Reserving 8193 would be rejected. So let's test that at limit, IsOverloaded is false
|
||||
tracker.TryReserve("conn-1", 8192);
|
||||
|
||||
// At exactly the limit, IsOverloaded is false (8192 > 8192 = false)
|
||||
Assert.False(tracker.IsOverloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOverloaded_FalseWhenWithinLimit()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.TryReserve("conn-1", 4000);
|
||||
|
||||
Assert.False(tracker.IsOverloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionInflightBytes_ReturnsZeroForUnknownConnection()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
var result = tracker.GetConnectionInflightBytes("unknown");
|
||||
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
}
|
||||
|
||||
public class ByteCountingStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_CountsBytesRead()
|
||||
{
|
||||
var data = new byte[] { 1, 2, 3, 4, 5 };
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
var buffer = new byte[10];
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
|
||||
Assert.Equal(5, read);
|
||||
Assert.Equal(5, stream.BytesRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ThrowsWhenLimitExceeded()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 50);
|
||||
|
||||
var buffer = new byte[100];
|
||||
|
||||
var ex = await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
|
||||
Assert.Equal(100, ex.BytesRead);
|
||||
Assert.Equal(50, ex.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_CallsCallbackOnLimitExceeded()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
var callbackCalled = false;
|
||||
using var stream = new ByteCountingStream(inner, 50, () => callbackCalled = true);
|
||||
|
||||
var buffer = new byte[100];
|
||||
|
||||
await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
|
||||
Assert.True(callbackCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_AccumulatesAcrossMultipleReads()
|
||||
{
|
||||
var data = new byte[100];
|
||||
using var inner = new MemoryStream(data);
|
||||
using var stream = new ByteCountingStream(inner, 60);
|
||||
|
||||
var buffer = new byte[30];
|
||||
|
||||
// First read - 30 bytes
|
||||
var read1 = await stream.ReadAsync(buffer);
|
||||
Assert.Equal(30, read1);
|
||||
Assert.Equal(30, stream.BytesRead);
|
||||
|
||||
// Second read - 30 more bytes
|
||||
var read2 = await stream.ReadAsync(buffer);
|
||||
Assert.Equal(30, read2);
|
||||
Assert.Equal(60, stream.BytesRead);
|
||||
|
||||
// Third read should exceed limit
|
||||
await Assert.ThrowsAsync<PayloadLimitExceededException>(
|
||||
() => stream.ReadAsync(buffer).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.True(stream.CanRead);
|
||||
Assert.False(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ThrowsNotSupported()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write(new byte[10], 0, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seek_ThrowsNotSupported()
|
||||
{
|
||||
using var inner = new MemoryStream();
|
||||
using var stream = new ByteCountingStream(inner, 100);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Seek(0, SeekOrigin.Begin));
|
||||
}
|
||||
}
|
||||
|
||||
public class PayloadLimitExceededExceptionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsProperties()
|
||||
{
|
||||
var ex = new PayloadLimitExceededException(1000, 500);
|
||||
|
||||
Assert.Equal(1000, ex.BytesRead);
|
||||
Assert.Equal(500, ex.Limit);
|
||||
Assert.Contains("1000", ex.Message);
|
||||
Assert.Contains("500", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RoutingDecisionMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutingDecisionMiddlewareTests
|
||||
{
|
||||
private readonly Mock<IRoutingPlugin> _routingPluginMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private readonly GatewayNodeConfig _gatewayConfig;
|
||||
private readonly RoutingOptions _routingOptions;
|
||||
private bool _nextCalled;
|
||||
|
||||
public RoutingDecisionMiddlewareTests()
|
||||
{
|
||||
_routingPluginMock = new Mock<IRoutingPlugin>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_gatewayConfig = new GatewayNodeConfig
|
||||
{
|
||||
Region = "us-east-1",
|
||||
NodeId = "gw-01",
|
||||
Environment = "test"
|
||||
};
|
||||
|
||||
_routingOptions = new RoutingOptions
|
||||
{
|
||||
DefaultVersion = "1.0.0"
|
||||
};
|
||||
}
|
||||
|
||||
private RoutingDecisionMiddleware CreateMiddleware()
|
||||
{
|
||||
return new RoutingDecisionMiddleware(_nextMock.Object);
|
||||
}
|
||||
|
||||
private HttpContext CreateHttpContext(EndpointDescriptor? endpoint = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "GET";
|
||||
context.Request.Path = "/api/test";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (endpoint is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0")
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test"
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
#region Missing Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoEndpoint_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(endpoint: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("descriptor missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Available Instance Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_SetsRoutingDecision()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var connection = CreateConnection();
|
||||
var decision = CreateDecision(endpoint, connection);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision].Should().Be(decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithAvailableInstance_CallsNext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region No Instances Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_Returns503()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoInstances_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([]);
|
||||
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RoutingDecision?)null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("No instances available");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Context Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesCorrectRoutingContext()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var connection = CreateConnection();
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path))
|
||||
.Returns([connection]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.Method.Should().Be("GET");
|
||||
capturedContext.Path.Should().Be("/api/test");
|
||||
capturedContext.GatewayRegion.Should().Be("us-east-1");
|
||||
capturedContext.Endpoint.Should().Be(endpoint);
|
||||
capturedContext.AvailableConnections.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_PassesRequestHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Custom-Header"] = "CustomValue";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.Headers.Should().ContainKey("X-Custom-Header");
|
||||
capturedContext.Headers["X-Custom-Header"].Should().Be("CustomValue");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Version Extraction Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithXApiVersionHeader_ExtractsVersion()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
context.Request.Headers["X-Api-Version"] = "2.0.0";
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("2.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoVersionHeader_UsesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint();
|
||||
var decision = CreateDecision(endpoint);
|
||||
var context = CreateHttpContext(endpoint: endpoint);
|
||||
|
||||
_routingStateMock.Setup(r => r.GetConnectionsFor(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns([CreateConnection()]);
|
||||
|
||||
RoutingContext? capturedContext = null;
|
||||
_routingPluginMock.Setup(p => p.ChooseInstanceAsync(
|
||||
It.IsAny<RoutingContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<RoutingContext, CancellationToken>((ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(decision);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_routingPluginMock.Object,
|
||||
_routingStateMock.Object,
|
||||
Options.Create(_gatewayConfig),
|
||||
Options.Create(_routingOptions));
|
||||
|
||||
// Assert
|
||||
capturedContext!.RequestedVersion.Should().Be("1.0.0"); // From _routingOptions
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- Disable Concelier test infrastructure - not needed for Gateway tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,315 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Microservice.Streaming;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class StreamingTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamDataPayload_HasRequiredProperties()
|
||||
{
|
||||
var payload = new StreamDataPayload
|
||||
{
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
Data = new byte[] { 1, 2, 3 },
|
||||
EndOfStream = true,
|
||||
SequenceNumber = 5
|
||||
};
|
||||
|
||||
Assert.NotEqual(Guid.Empty, payload.CorrelationId);
|
||||
Assert.Equal(3, payload.Data.Length);
|
||||
Assert.True(payload.EndOfStream);
|
||||
Assert.Equal(5, payload.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingOptions_HasDefaultValues()
|
||||
{
|
||||
var options = StreamingOptions.Default;
|
||||
|
||||
Assert.Equal(64 * 1024, options.ChunkSize);
|
||||
Assert.Equal(100, options.MaxConcurrentStreams);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), options.StreamIdleTimeout);
|
||||
Assert.Equal(16, options.ChannelCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
public class StreamingRequestBodyStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsDataFromChannel()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
var testData = new byte[] { 1, 2, 3, 4, 5 };
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = testData, SequenceNumber = 0 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 1 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
var buffer = new byte[10];
|
||||
var bytesRead = await stream.ReadAsync(buffer);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, bytesRead);
|
||||
Assert.Equal(testData, buffer[..5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsZeroAtEndOfStream()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 0 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
var buffer = new byte[10];
|
||||
var bytesRead = await stream.ReadAsync(buffer);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, bytesRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_HandlesMultipleChunks()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [1, 2, 3], SequenceNumber = 0 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [4, 5, 6], SequenceNumber = 1 });
|
||||
await channel.Writer.WriteAsync(new StreamChunk { Data = [], EndOfStream = true, SequenceNumber = 2 });
|
||||
channel.Writer.Complete();
|
||||
|
||||
// Act
|
||||
using var memStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memStream);
|
||||
|
||||
// Assert
|
||||
var result = memStream.ToArray();
|
||||
Assert.Equal(6, result.Length);
|
||||
Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
Assert.True(stream.CanRead);
|
||||
Assert.False(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ThrowsNotSupported()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingRequestBodyStream(channel.Reader, CancellationToken.None);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Write([1, 2, 3], 0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
public class StreamingResponseBodyStreamTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAsync_WritesToChannel()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
var testData = new byte[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(testData);
|
||||
await stream.FlushAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(channel.Reader.TryRead(out var chunk));
|
||||
Assert.Equal(testData, chunk!.Data);
|
||||
Assert.False(chunk.EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_SendsEndOfStream()
|
||||
{
|
||||
// Arrange
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(new byte[] { 1, 2, 3 });
|
||||
await stream.CompleteAsync();
|
||||
|
||||
// Assert - should have data chunk + end chunk
|
||||
var chunks = new List<StreamChunk>();
|
||||
await foreach (var chunk in channel.Reader.ReadAllAsync())
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
Assert.Equal(2, chunks.Count);
|
||||
Assert.False(chunks[0].EndOfStream);
|
||||
Assert.True(chunks[1].EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ChunksLargeData()
|
||||
{
|
||||
// Arrange
|
||||
var chunkSize = 10;
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
await using var stream = new StreamingResponseBodyStream(channel.Writer, chunkSize, CancellationToken.None);
|
||||
|
||||
var testData = new byte[25]; // Will need 3 chunks
|
||||
for (var i = 0; i < testData.Length; i++)
|
||||
{
|
||||
testData[i] = (byte)i;
|
||||
}
|
||||
|
||||
// Act
|
||||
await stream.WriteAsync(testData);
|
||||
await stream.CompleteAsync();
|
||||
|
||||
// Assert
|
||||
var chunks = new List<StreamChunk>();
|
||||
await foreach (var chunk in channel.Reader.ReadAllAsync())
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
// Should have 3 chunks (10+10+5) + 1 end-of-stream (with 0 data since remainder already flushed)
|
||||
Assert.Equal(4, chunks.Count);
|
||||
Assert.Equal(10, chunks[0].Data.Length);
|
||||
Assert.Equal(10, chunks[1].Data.Length);
|
||||
Assert.Equal(5, chunks[2].Data.Length);
|
||||
Assert.True(chunks[3].EndOfStream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stream_Properties_AreCorrect()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
Assert.False(stream.CanRead);
|
||||
Assert.True(stream.CanWrite);
|
||||
Assert.False(stream.CanSeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ThrowsNotSupported()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<StreamChunk>();
|
||||
using var stream = new StreamingResponseBodyStream(channel.Writer, 1024, CancellationToken.None);
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => stream.Read(new byte[10], 0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
public class InMemoryTransportStreamingTests
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry = new();
|
||||
private readonly InMemoryTransportOptions _options = new() { SimulatedLatency = TimeSpan.Zero };
|
||||
|
||||
private InMemoryTransportClient CreateClient()
|
||||
{
|
||||
return new InMemoryTransportClient(
|
||||
_registry,
|
||||
Options.Create(_options),
|
||||
NullLogger<InMemoryTransportClient>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendStreamingAsync_SendsRequestStreamDataFrames()
|
||||
{
|
||||
// Arrange
|
||||
using var client = CreateClient();
|
||||
var instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "test-instance",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
};
|
||||
|
||||
await client.ConnectAsync(instance, [], CancellationToken.None);
|
||||
|
||||
// Get connection ID via reflection
|
||||
var connectionIdField = client.GetType()
|
||||
.GetField("_connectionId", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var connectionId = connectionIdField?.GetValue(client)?.ToString();
|
||||
Assert.NotNull(connectionId);
|
||||
|
||||
var channel = _registry.GetChannel(connectionId!);
|
||||
Assert.NotNull(channel);
|
||||
Assert.NotNull(channel!.State);
|
||||
|
||||
// Create request body stream
|
||||
var requestBody = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
// Create request frame
|
||||
var requestFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
};
|
||||
|
||||
var limits = PayloadLimits.Default;
|
||||
|
||||
// Act - Start streaming (this will send frames to microservice)
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var sendTask = client.SendStreamingAsync(
|
||||
channel.State!,
|
||||
requestFrame,
|
||||
requestBody,
|
||||
_ => Task.CompletedTask,
|
||||
limits,
|
||||
cts.Token);
|
||||
|
||||
// Read the frames that were sent to microservice
|
||||
var frames = new List<Frame>();
|
||||
await foreach (var frame in channel.ToMicroservice.Reader.ReadAllAsync(cts.Token))
|
||||
{
|
||||
frames.Add(frame);
|
||||
if (frame.Type == FrameType.RequestStreamData && frame.Payload.Length == 0)
|
||||
{
|
||||
// End of stream - break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - should have REQUEST header + data chunks + end-of-stream
|
||||
Assert.True(frames.Count >= 2);
|
||||
Assert.Equal(FrameType.Request, frames[0].Type);
|
||||
Assert.Equal(FrameType.RequestStreamData, frames[^1].Type);
|
||||
Assert.Equal(0, frames[^1].Payload.Length); // End of stream marker
|
||||
}
|
||||
}
|
||||
@@ -1,786 +0,0 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TransportDispatchMiddleware"/>.
|
||||
/// </summary>
|
||||
public sealed class TransportDispatchMiddlewareTests
|
||||
{
|
||||
private readonly Mock<ITransportClient> _transportClientMock;
|
||||
private readonly Mock<IGlobalRoutingState> _routingStateMock;
|
||||
private readonly Mock<RequestDelegate> _nextMock;
|
||||
private bool _nextCalled;
|
||||
|
||||
public TransportDispatchMiddlewareTests()
|
||||
{
|
||||
_transportClientMock = new Mock<ITransportClient>();
|
||||
_routingStateMock = new Mock<IGlobalRoutingState>();
|
||||
_nextMock = new Mock<RequestDelegate>();
|
||||
_nextMock.Setup(n => n(It.IsAny<HttpContext>()))
|
||||
.Callback(() => _nextCalled = true)
|
||||
.Returns(Task.CompletedTask);
|
||||
}
|
||||
|
||||
private TransportDispatchMiddleware CreateMiddleware()
|
||||
{
|
||||
return new TransportDispatchMiddleware(
|
||||
_nextMock.Object,
|
||||
NullLogger<TransportDispatchMiddleware>.Instance);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext(
|
||||
RoutingDecision? decision = null,
|
||||
string method = "GET",
|
||||
string path = "/api/test",
|
||||
byte[]? body = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = method;
|
||||
context.Request.Path = path;
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
context.Request.Body = new MemoryStream(body);
|
||||
context.Request.ContentLength = body.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Request.Body = new MemoryStream();
|
||||
}
|
||||
|
||||
if (decision is not null)
|
||||
{
|
||||
context.Items[RouterHttpContextKeys.RoutingDecision] = decision;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static EndpointDescriptor CreateEndpoint(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
bool supportsStreaming = false)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Method = "GET",
|
||||
Path = "/api/test",
|
||||
SupportsStreaming = supportsStreaming
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateConnection(
|
||||
string connectionId = "conn-1",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = $"inst-{connectionId}",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "us-east-1"
|
||||
},
|
||||
Status = status,
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
private static RoutingDecision CreateDecision(
|
||||
EndpointDescriptor? endpoint = null,
|
||||
ConnectionState? connection = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
return new RoutingDecision
|
||||
{
|
||||
Endpoint = endpoint ?? CreateEndpoint(),
|
||||
Connection = connection ?? CreateConnection(),
|
||||
TransportType = TransportType.InMemory,
|
||||
EffectiveTimeout = timeout ?? TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
private static Frame CreateResponseFrame(
|
||||
string requestId = "test-request",
|
||||
int statusCode = 200,
|
||||
Dictionary<string, string>? headers = null,
|
||||
byte[]? payload = null)
|
||||
{
|
||||
var response = new ResponseFrame
|
||||
{
|
||||
RequestId = requestId,
|
||||
StatusCode = statusCode,
|
||||
Headers = headers ?? new Dictionary<string, string>(),
|
||||
Payload = payload ?? []
|
||||
};
|
||||
|
||||
return FrameConverter.ToFrame(response);
|
||||
}
|
||||
|
||||
#region Missing Routing Decision Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_nextCalled.Should().BeFalse();
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithNoRoutingDecision_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext(decision: null);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Routing decision missing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Successful Request/Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_ForwardsStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, statusCode: 201);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(201);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponsePayload_WritesToResponseBody()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responsePayload = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, payload: responsePayload);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Be("{\"result\":\"success\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithResponseHeaders_ForwardsHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["X-Custom-Header"] = "CustomValue",
|
||||
["Content-Type"] = "application/json"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
context.Response.Headers["X-Custom-Header"].ToString().Should().Be("CustomValue");
|
||||
context.Response.Headers["Content-Type"].ToString().Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTransferEncodingHeader_DoesNotForward()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
var responseHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Transfer-Encoding"] = "chunked",
|
||||
["X-Custom-Header"] = "CustomValue"
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId, headers: responseHeaders);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Headers.Should().NotContainKey("Transfer-Encoding");
|
||||
context.Response.Headers.Should().ContainKey("X-Custom-Header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestBody_SendsBodyInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var requestBody = Encoding.UTF8.GetBytes("{\"data\":\"test\"}");
|
||||
var context = CreateHttpContext(decision: decision, body: requestBody);
|
||||
|
||||
byte[]? capturedPayload = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPayload = requestFrame?.Payload.ToArray();
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPayload.Should().BeEquivalentTo(requestBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithRequestHeaders_ForwardsHeadersInFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
context.Request.Headers["X-Request-Id"] = "req-123";
|
||||
context.Request.Headers["Accept"] = "application/json";
|
||||
|
||||
IReadOnlyDictionary<string, string>? capturedHeaders = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedHeaders = requestFrame?.Headers;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedHeaders.Should().NotBeNull();
|
||||
capturedHeaders.Should().ContainKey("X-Request-Id");
|
||||
capturedHeaders!["X-Request-Id"].Should().Be("req-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timeout Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream timeout");
|
||||
responseBody.Should().Contain("test-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithTimeout_SendsCancelFrame()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision(timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendCancelAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Guid>(),
|
||||
CancelReasons.Timeout), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upstream Error Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithUpstreamError_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Upstream error");
|
||||
responseBody.Should().Contain("Connection failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Response Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
// Return a malformed frame that cannot be parsed as ResponseFrame
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithInvalidResponseFrame_WritesErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
var invalidFrame = new Frame
|
||||
{
|
||||
Type = FrameType.Cancel, // Wrong type
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(invalidFrame);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var responseBody = await reader.ReadToEndAsync();
|
||||
|
||||
responseBody.Should().Contain("Invalid upstream response");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Connection Ping Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithSuccessfulResponse_UpdatesConnectionPing()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_routingStateMock.Verify(r => r.UpdateConnection(
|
||||
"conn-1",
|
||||
It.IsAny<Action<ConnectionState>>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithStreamingEndpoint_UsesSendStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, Stream, Func<Stream, Task>, PayloadLimits, CancellationToken>(
|
||||
async (conn, req, requestBody, readResponse, limits, ct) =>
|
||||
{
|
||||
// Simulate streaming response
|
||||
using var responseStream = new MemoryStream(Encoding.UTF8.GetBytes("streamed data"));
|
||||
await readResponse(responseStream);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
_transportClientMock.Verify(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithTimeout_Returns504()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint, timeout: TimeSpan.FromMilliseconds(50));
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status504GatewayTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_StreamingWithUpstreamError_Returns502()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var endpoint = CreateEndpoint(supportsStreaming: true);
|
||||
var decision = CreateDecision(endpoint: endpoint);
|
||||
var context = CreateHttpContext(decision: decision);
|
||||
|
||||
_transportClientMock.Setup(t => t.SendStreamingAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<Func<Stream, Task>>(),
|
||||
It.IsAny<PayloadLimits>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Streaming failed"));
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query String Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Invoke_WithQueryString_IncludesInRequestPath()
|
||||
{
|
||||
// Arrange
|
||||
var middleware = CreateMiddleware();
|
||||
var decision = CreateDecision();
|
||||
var context = CreateHttpContext(decision: decision, path: "/api/test");
|
||||
context.Request.QueryString = new QueryString("?key=value&other=123");
|
||||
|
||||
string? capturedPath = null;
|
||||
_transportClientMock.Setup(t => t.SendRequestAsync(
|
||||
It.IsAny<ConnectionState>(),
|
||||
It.IsAny<Frame>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConnectionState, Frame, TimeSpan, CancellationToken>((conn, req, timeout, ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
capturedPath = requestFrame?.Path;
|
||||
})
|
||||
.ReturnsAsync((ConnectionState conn, Frame req, TimeSpan timeout, CancellationToken ct) =>
|
||||
{
|
||||
var requestFrame = FrameConverter.ToRequestFrame(req);
|
||||
return CreateResponseFrame(requestId: requestFrame!.RequestId);
|
||||
});
|
||||
|
||||
// Act
|
||||
await middleware.Invoke(
|
||||
context,
|
||||
_transportClientMock.Object,
|
||||
_routingStateMock.Object);
|
||||
|
||||
// Assert
|
||||
capturedPath.Should().Be("/api/test?key=value&other=123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user