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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

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

View File

@@ -1,4 +1,5 @@
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
namespace StellaOps.Gateway.WebService.Authorization;

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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";
});
});
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": true
}