feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Authorization;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Configuration;
|
||||
@@ -13,6 +14,7 @@ using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.DependencyInjection;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.OpenApi;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
|
||||
@@ -119,7 +119,7 @@ public sealed class GatewayHostedService : IHostedService
|
||||
|
||||
private void HandleTlsFrame(string connectionId, Frame frame)
|
||||
{
|
||||
_ = HandleFrameAsync(TransportType.Tls, connectionId, frame);
|
||||
_ = HandleFrameAsync(TransportType.Certificate, connectionId, frame);
|
||||
}
|
||||
|
||||
private void HandleTcpDisconnection(string connectionId)
|
||||
@@ -434,7 +434,7 @@ public sealed class GatewayHostedService : IHostedService
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportType == TransportType.Tls)
|
||||
if (transportType == TransportType.Certificate)
|
||||
{
|
||||
_tlsServer.GetConnection(connectionId)?.Close();
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public sealed class GatewayTransportClient : ITransportClient
|
||||
case TransportType.Tcp:
|
||||
await _tcpServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
|
||||
break;
|
||||
case TransportType.Tls:
|
||||
case TransportType.Certificate:
|
||||
await _tlsServer.SendFrameAsync(connection.ConnectionId, frame, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidatorTests
|
||||
{
|
||||
private static GatewayOptions CreateValidOptions()
|
||||
{
|
||||
return new GatewayOptions
|
||||
{
|
||||
Node = new GatewayNodeOptions
|
||||
{
|
||||
Region = "eu1",
|
||||
NodeId = "gw-01",
|
||||
Environment = "test"
|
||||
},
|
||||
Transports = new GatewayTransportOptions
|
||||
{
|
||||
Tcp = new GatewayTcpTransportOptions { Enabled = false },
|
||||
Tls = new GatewayTlsTransportOptions { Enabled = false }
|
||||
},
|
||||
Routing = new GatewayRoutingOptions
|
||||
{
|
||||
DefaultTimeout = "30s",
|
||||
MaxRequestBodySize = "100MB"
|
||||
},
|
||||
Health = new GatewayHealthOptions
|
||||
{
|
||||
StaleThreshold = "30s",
|
||||
DegradedThreshold = "15s",
|
||||
CheckInterval = "5s"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidOptions_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => GatewayOptionsValidator.Validate(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_EmptyRegion_ThrowsInvalidOperationException(string? region)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Node.Region = region!;
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("region", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Validate_TcpEnabled_InvalidPort_ThrowsException(int port)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Transports.Tcp.Enabled = true;
|
||||
options.Transports.Tcp.Port = port;
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("TCP", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("port", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TcpEnabled_ValidPort_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Transports.Tcp.Enabled = true;
|
||||
options.Transports.Tcp.Port = 9100;
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Validate_TlsEnabled_InvalidPort_ThrowsException(int port)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Transports.Tls.Enabled = true;
|
||||
options.Transports.Tls.Port = port;
|
||||
options.Transports.Tls.CertificatePath = "/certs/server.pfx";
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("TLS", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_TlsEnabled_NoCertificatePath_ThrowsException(string? certPath)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Transports.Tls.Enabled = true;
|
||||
options.Transports.Tls.Port = 9443;
|
||||
options.Transports.Tls.CertificatePath = certPath;
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
|
||||
Assert.Contains("certificate", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TlsEnabled_ValidConfig_DoesNotThrow()
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Transports.Tls.Enabled = true;
|
||||
options.Transports.Tls.Port = 9443;
|
||||
options.Transports.Tls.CertificatePath = "/certs/server.pfx";
|
||||
|
||||
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("10x")]
|
||||
public void Validate_InvalidDurationFormat_ThrowsException(string duration)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routing.DefaultTimeout = duration;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("10TB")]
|
||||
public void Validate_InvalidSizeFormat_ThrowsException(string size)
|
||||
{
|
||||
var options = CreateValidOptions();
|
||||
options.Routing.MaxRequestBodySize = size;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayOptionsValidator.Validate(options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayValueParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("30s", 30)]
|
||||
[InlineData("5m", 300)]
|
||||
[InlineData("1h", 3600)]
|
||||
[InlineData("500ms", 0.5)]
|
||||
[InlineData("1.5s", 1.5)]
|
||||
[InlineData("0.5h", 1800)]
|
||||
public void ParseDuration_ValidValues_ReturnsExpectedTimeSpan(string input, double expectedSeconds)
|
||||
{
|
||||
var result = GatewayValueParser.ParseDuration(input, TimeSpan.Zero);
|
||||
Assert.Equal(expectedSeconds, result.TotalSeconds, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDuration_StandardTimeSpanFormat_Works()
|
||||
{
|
||||
var result = GatewayValueParser.ParseDuration("00:01:30", TimeSpan.Zero);
|
||||
Assert.Equal(90, result.TotalSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDuration_NullOrEmpty_ReturnsFallback()
|
||||
{
|
||||
var fallback = TimeSpan.FromSeconds(42);
|
||||
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseDuration(null, fallback));
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseDuration("", fallback));
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseDuration(" ", fallback));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("10x")]
|
||||
[InlineData("abc123")]
|
||||
public void ParseDuration_InvalidFormat_ThrowsException(string input)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayValueParser.ParseDuration(input, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("100", 100)]
|
||||
[InlineData("100b", 100)]
|
||||
[InlineData("1KB", 1024)]
|
||||
[InlineData("1kb", 1024)]
|
||||
[InlineData("1MB", 1024 * 1024)]
|
||||
[InlineData("100MB", 100L * 1024 * 1024)]
|
||||
[InlineData("1GB", 1024L * 1024 * 1024)]
|
||||
[InlineData("1.5MB", (long)(1.5 * 1024 * 1024))]
|
||||
public void ParseSizeBytes_ValidValues_ReturnsExpectedBytes(string input, long expected)
|
||||
{
|
||||
var result = GatewayValueParser.ParseSizeBytes(input, 0);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSizeBytes_NullOrEmpty_ReturnsFallback()
|
||||
{
|
||||
const long fallback = 999;
|
||||
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseSizeBytes(null, fallback));
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseSizeBytes("", fallback));
|
||||
Assert.Equal(fallback, GatewayValueParser.ParseSizeBytes(" ", fallback));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("10TB")]
|
||||
[InlineData("abc123")]
|
||||
public void ParseSizeBytes_InvalidFormat_ThrowsException(string input)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
GatewayValueParser.ParseSizeBytes(input, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Integration;
|
||||
|
||||
public sealed class GatewayIntegrationTests : IClassFixture<GatewayWebApplicationFactory>
|
||||
{
|
||||
private readonly GatewayWebApplicationFactory _factory;
|
||||
|
||||
public GatewayIntegrationTests(GatewayWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReturnsHealthy()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthLive_ReturnsOk()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health/live");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsOk()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApiJson_ReturnsValidOpenApiDocument()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/openapi.json");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString());
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"openapi\"", content);
|
||||
Assert.Contains("\"3.1.0\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApiYaml_ReturnsValidYaml()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/openapi.yaml");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/yaml; charset=utf-8", response.Content.Headers.ContentType?.ToString());
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("openapi:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApiDiscovery_ReturnsWellKnownEndpoints()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/.well-known/openapi");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("openapi_json", content);
|
||||
Assert.Contains("openapi_yaml", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApiJson_WithETag_ReturnsNotModified()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// First request to get ETag
|
||||
var response1 = await client.GetAsync("/openapi.json");
|
||||
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
|
||||
var etag = response1.Headers.ETag?.Tag;
|
||||
Assert.NotNull(etag);
|
||||
|
||||
// Second request with If-None-Match
|
||||
var request2 = new HttpRequestMessage(HttpMethod.Get, "/openapi.json");
|
||||
request2.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||
var response2 = await client.SendAsync(request2);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, response2.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Metrics_ReturnsPrometheusFormat()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/metrics");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownRoute_WithNoRegisteredMicroservices_Returns404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/unknown");
|
||||
|
||||
// Without registered microservices, unmatched routes should return 404
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelationId_IsReturnedInResponse()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health");
|
||||
|
||||
Assert.True(response.Headers.Contains("X-Correlation-Id"));
|
||||
var correlationId = response.Headers.GetValues("X-Correlation-Id").FirstOrDefault();
|
||||
Assert.False(string.IsNullOrEmpty(correlationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrelationId_ProvidedInRequest_IsEchoed()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var requestCorrelationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
request.Headers.TryAddWithoutValidation("X-Correlation-Id", requestCorrelationId);
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.True(response.Headers.Contains("X-Correlation-Id"));
|
||||
var responseCorrelationId = response.Headers.GetValues("X-Correlation-Id").FirstOrDefault();
|
||||
Assert.Equal(requestCorrelationId, responseCorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory for Gateway integration tests.
|
||||
/// </summary>
|
||||
public sealed class GatewayWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Override configuration for testing
|
||||
services.Configure<RouterNodeConfig>(config =>
|
||||
{
|
||||
config.Region = "test";
|
||||
config.NodeId = "test-gateway-01";
|
||||
config.Environment = "test";
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Middleware;
|
||||
|
||||
public sealed class ClaimsPropagationMiddlewareTests
|
||||
{
|
||||
private readonly ClaimsPropagationMiddleware _middleware;
|
||||
private bool _nextCalled;
|
||||
|
||||
public ClaimsPropagationMiddlewareTests()
|
||||
{
|
||||
_nextCalled = false;
|
||||
_middleware = new ClaimsPropagationMiddleware(
|
||||
_ =>
|
||||
{
|
||||
_nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
NullLogger<ClaimsPropagationMiddleware>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SystemPath_SkipsProcessing()
|
||||
{
|
||||
var context = CreateHttpContext("/health");
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("sub"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithSubClaim_SetsSubHeader()
|
||||
{
|
||||
const string subject = "user-123";
|
||||
var context = CreateHttpContext("/api/scan", new Claim("sub", subject));
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal(subject, context.Request.Headers["sub"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithTidClaim_SetsTidHeader()
|
||||
{
|
||||
const string tenantId = "tenant-456";
|
||||
var context = CreateHttpContext("/api/scan", new Claim("tid", tenantId));
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal(tenantId, context.Request.Headers["tid"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithScopeClaims_JoinsAndSetsScopeHeader()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("scope", "read"),
|
||||
new Claim("scope", "write"),
|
||||
new Claim("scope", "admin")
|
||||
};
|
||||
var context = CreateHttpContext("/api/scan", claims);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal("read write admin", context.Request.Headers["scope"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithCnfClaim_ParsesJkt()
|
||||
{
|
||||
const string jkt = "thumbprint-abc123";
|
||||
var cnfJson = $"{{\"jkt\":\"{jkt}\"}}";
|
||||
var context = CreateHttpContext("/api/scan", new Claim("cnf", cnfJson));
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal(jkt, context.Request.Headers["cnf.jkt"].ToString());
|
||||
Assert.Equal(cnfJson, context.Items[GatewayContextKeys.CnfJson]);
|
||||
Assert.Equal(jkt, context.Items[GatewayContextKeys.DpopThumbprint]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithInvalidCnfJson_DoesNotThrow()
|
||||
{
|
||||
var context = CreateHttpContext("/api/scan", new Claim("cnf", "invalid-json"));
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => _middleware.InvokeAsync(context));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("cnf.jkt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExistingHeader_DoesNotOverwrite()
|
||||
{
|
||||
const string existingSubject = "existing-user";
|
||||
const string claimSubject = "claim-user";
|
||||
var context = CreateHttpContext("/api/scan", new Claim("sub", claimSubject));
|
||||
context.Request.Headers["sub"] = existingSubject;
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(existingSubject, context.Request.Headers["sub"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoScopeClaims_DoesNotSetScopeHeader()
|
||||
{
|
||||
var context = CreateHttpContext("/api/scan", new Claim("sub", "user-123"));
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("scope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoClaims_DoesNotSetHeaders()
|
||||
{
|
||||
var context = CreateHttpContext("/api/scan");
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Request.Headers.ContainsKey("sub"));
|
||||
Assert.False(context.Request.Headers.ContainsKey("tid"));
|
||||
Assert.False(context.Request.Headers.ContainsKey("scope"));
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(string path, params Claim[] claims)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = new PathString(path);
|
||||
|
||||
if (claims.Length > 0)
|
||||
{
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Middleware;
|
||||
|
||||
public sealed class CorrelationIdMiddlewareTests
|
||||
{
|
||||
private readonly CorrelationIdMiddleware _middleware;
|
||||
private bool _nextCalled;
|
||||
|
||||
public CorrelationIdMiddlewareTests()
|
||||
{
|
||||
_nextCalled = false;
|
||||
_middleware = new CorrelationIdMiddleware(_ =>
|
||||
{
|
||||
_nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoCorrelationIdHeader_GeneratesNewId()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.True(context.Response.Headers.ContainsKey("X-Correlation-Id"));
|
||||
var correlationId = context.Response.Headers["X-Correlation-Id"].ToString();
|
||||
Assert.False(string.IsNullOrEmpty(correlationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithCorrelationIdHeader_PreservesExistingId()
|
||||
{
|
||||
const string existingId = "existing-correlation-id-123";
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers["X-Correlation-Id"] = existingId;
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal(existingId, context.Response.Headers["X-Correlation-Id"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoHeader_UsesExistingOrGeneratesTraceId()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
var correlationId = context.Response.Headers["X-Correlation-Id"].ToString();
|
||||
// DefaultHttpContext provides a default TraceIdentifier, so the middleware uses it
|
||||
Assert.False(string.IsNullOrEmpty(correlationId));
|
||||
Assert.Equal(context.TraceIdentifier, correlationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SetsTraceIdentifier()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
var correlationId = context.Response.Headers["X-Correlation-Id"].ToString();
|
||||
Assert.Equal(correlationId, context.TraceIdentifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Middleware;
|
||||
|
||||
public sealed class GatewayRoutesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/health", true)]
|
||||
[InlineData("/health/live", true)]
|
||||
[InlineData("/health/ready", true)]
|
||||
[InlineData("/health/startup", true)]
|
||||
[InlineData("/metrics", true)]
|
||||
[InlineData("/openapi.json", true)]
|
||||
[InlineData("/openapi.yaml", true)]
|
||||
[InlineData("/.well-known/openapi", true)]
|
||||
[InlineData("/api/v1/scan", false)]
|
||||
[InlineData("/users", false)]
|
||||
[InlineData("/", false)]
|
||||
[InlineData("/api/health", false)]
|
||||
public void IsSystemPath_ReturnsExpectedResult(string path, bool expected)
|
||||
{
|
||||
var pathString = new PathString(path);
|
||||
var result = GatewayRoutes.IsSystemPath(pathString);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/HEALTH", true)]
|
||||
[InlineData("/Health/Live", true)]
|
||||
[InlineData("/OPENAPI.JSON", true)]
|
||||
public void IsSystemPath_IsCaseInsensitive(string path, bool expected)
|
||||
{
|
||||
var pathString = new PathString(path);
|
||||
var result = GatewayRoutes.IsSystemPath(pathString);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/health", true)]
|
||||
[InlineData("/health/live", true)]
|
||||
[InlineData("/health/ready", true)]
|
||||
[InlineData("/health/startup", true)]
|
||||
[InlineData("/health/custom", true)]
|
||||
[InlineData("/healthcheck", true)]
|
||||
[InlineData("/healthy", true)]
|
||||
[InlineData("/metrics", false)]
|
||||
[InlineData("/api/health", false)]
|
||||
public void IsHealthPath_ReturnsExpectedResult(string path, bool expected)
|
||||
{
|
||||
var pathString = new PathString(path);
|
||||
var result = GatewayRoutes.IsHealthPath(pathString);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/HEALTH/LIVE", true)]
|
||||
[InlineData("/Health/Ready", true)]
|
||||
public void IsHealthPath_IsCaseInsensitive(string path, bool expected)
|
||||
{
|
||||
var pathString = new PathString(path);
|
||||
var result = GatewayRoutes.IsHealthPath(pathString);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/metrics", true)]
|
||||
[InlineData("/METRICS", true)]
|
||||
[InlineData("/Metrics", true)]
|
||||
[InlineData("/metrics/", false)]
|
||||
[InlineData("/metrics/custom", false)]
|
||||
[InlineData("/api/metrics", false)]
|
||||
public void IsMetricsPath_ReturnsExpectedResult(string path, bool expected)
|
||||
{
|
||||
var pathString = new PathString(path);
|
||||
var result = GatewayRoutes.IsMetricsPath(pathString);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSystemPath_EmptyPath_ReturnsFalse()
|
||||
{
|
||||
var result = GatewayRoutes.IsSystemPath(new PathString());
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSystemPath_NullPath_ReturnsFalse()
|
||||
{
|
||||
var result = GatewayRoutes.IsSystemPath(default);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Middleware;
|
||||
|
||||
public sealed class TenantMiddlewareTests
|
||||
{
|
||||
private readonly TenantMiddleware _middleware;
|
||||
private bool _nextCalled;
|
||||
|
||||
public TenantMiddlewareTests()
|
||||
{
|
||||
_nextCalled = false;
|
||||
_middleware = new TenantMiddleware(
|
||||
_ =>
|
||||
{
|
||||
_nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
NullLogger<TenantMiddleware>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_SystemPath_SkipsProcessing()
|
||||
{
|
||||
var context = CreateHttpContext("/health");
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Items.ContainsKey(GatewayContextKeys.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithTenantClaim_SetsTenantIdInItems()
|
||||
{
|
||||
const string tenantId = "tenant-123";
|
||||
var context = CreateHttpContext("/api/scan", tenantId);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.Equal(tenantId, context.Items[GatewayContextKeys.TenantId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithTenantClaim_AddsTenantIdHeader()
|
||||
{
|
||||
const string tenantId = "tenant-456";
|
||||
var context = CreateHttpContext("/api/scan", tenantId);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(tenantId, context.Request.Headers["tid"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithExistingTidHeader_DoesNotOverwrite()
|
||||
{
|
||||
const string claimTenantId = "claim-tenant";
|
||||
const string headerTenantId = "header-tenant";
|
||||
var context = CreateHttpContext("/api/scan", claimTenantId);
|
||||
context.Request.Headers["tid"] = headerTenantId;
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(headerTenantId, context.Request.Headers["tid"]);
|
||||
Assert.Equal(claimTenantId, context.Items[GatewayContextKeys.TenantId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoTenantClaim_DoesNotSetTenantId()
|
||||
{
|
||||
var context = CreateHttpContext("/api/scan");
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Items.ContainsKey(GatewayContextKeys.TenantId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task InvokeAsync_EmptyTenantClaim_DoesNotSetTenantId(string tenantId)
|
||||
{
|
||||
var context = CreateHttpContext("/api/scan", tenantId);
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
Assert.False(context.Items.ContainsKey(GatewayContextKeys.TenantId));
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(string path, string? tenantId = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = new PathString(path);
|
||||
|
||||
if (tenantId is not null)
|
||||
{
|
||||
var claims = new List<Claim> { new("tid", tenantId) };
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Test packages inherited from Directory.Build.props -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": true
|
||||
}
|
||||
Reference in New Issue
Block a user