Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,265 @@
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 StellaOps.Router.Gateway;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests.Authorization;
/// <summary>
/// Tests for <see cref="AuthorizationMiddleware"/>.
/// </summary>
public sealed class AuthorizationMiddlewareTests
{
private readonly Mock<IEffectiveClaimsStore> _claimsStore;
private readonly Mock<RequestDelegate> _next;
private readonly AuthorizationMiddleware _middleware;
public AuthorizationMiddlewareTests()
{
_claimsStore = new Mock<IEffectiveClaimsStore>();
_next = new Mock<RequestDelegate>();
_middleware = new AuthorizationMiddleware(
_next.Object,
_claimsStore.Object,
NullLogger<AuthorizationMiddleware>.Instance);
}
[Fact]
public async Task InvokeAsync_NoEndpointResolved_CallsNext()
{
// Arrange
var context = CreateHttpContext();
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_NoClaims_CallsNext()
{
// Arrange
var context = CreateHttpContextWithEndpoint();
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(Array.Empty<ClaimRequirement>());
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(context), Times.Once);
context.Response.StatusCode.Should().NotBe(403);
}
[Fact]
public async Task InvokeAsync_UserHasRequiredClaims_CallsNext()
{
// Arrange
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "read"),
new Claim("role", "user")
});
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "read" },
new() { Type = "role", Value = "user" }
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(context), Times.Once);
context.Response.StatusCode.Should().NotBe(403);
}
[Fact]
public async Task InvokeAsync_UserMissingRequiredClaim_Returns403()
{
// Arrange
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "read")
});
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "read" },
new() { Type = "role", Value = "admin" } // User doesn't have this
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(It.IsAny<HttpContext>()), Times.Never);
context.Response.StatusCode.Should().Be(403);
}
[Fact]
public async Task InvokeAsync_UserHasClaimTypeButWrongValue_Returns403()
{
// Arrange
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("role", "user")
});
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "role", Value = "admin" }
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(It.IsAny<HttpContext>()), Times.Never);
context.Response.StatusCode.Should().Be(403);
}
[Fact]
public async Task InvokeAsync_ClaimWithNullValue_MatchesAnyValue()
{
// Arrange - user has claim of type "authenticated" with some value
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("authenticated", "true")
});
// Requirement only checks that type exists, any value is ok
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "authenticated", Value = null }
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_MultipleClaims_AllMustMatch()
{
// Arrange - user has 2 of 3 required claims
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "read"),
new Claim("role", "user")
});
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "read" },
new() { Type = "role", Value = "user" },
new() { Type = "department", Value = "IT" } // Missing
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(It.IsAny<HttpContext>()), Times.Never);
context.Response.StatusCode.Should().Be(403);
}
[Fact]
public async Task InvokeAsync_UserHasExtraClaims_StillAuthorized()
{
// Arrange - user has more claims than required
var context = CreateHttpContextWithEndpoint(new[]
{
new Claim("scope", "read"),
new Claim("scope", "write"),
new Claim("role", "admin"),
new Claim("department", "IT")
});
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "scope", Value = "read" }
});
// Act
await _middleware.InvokeAsync(context);
// Assert
_next.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
{
// Arrange
var context = CreateHttpContextWithEndpoint();
context.Response.Body = new MemoryStream();
_claimsStore
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
.Returns(new List<ClaimRequirement>
{
new() { Type = "admin", Value = "true" }
});
// Act
await _middleware.InvokeAsync(context);
// Assert
context.Response.StatusCode.Should().Be(403);
context.Response.ContentType.Should().Contain("application/json");
}
private static HttpContext CreateHttpContext()
{
var context = new DefaultHttpContext();
return context;
}
private static HttpContext CreateHttpContextWithEndpoint(Claim[]? userClaims = null)
{
var context = new DefaultHttpContext();
// Set resolved endpoint
var endpoint = new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = "GET",
Path = "/api/test"
};
context.Items[RouterHttpContextKeys.EndpointDescriptor] = endpoint;
// Set user with claims
if (userClaims != null)
{
var identity = new ClaimsIdentity(userClaims, "Test");
context.User = new ClaimsPrincipal(identity);
}
return context;
}
}

View File

@@ -0,0 +1,272 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Gateway.WebService.Authorization;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Authorization;
using Xunit;
namespace StellaOps.Gateway.WebService.Tests.Authorization;
/// <summary>
/// Tests for <see cref="EffectiveClaimsStore"/>.
/// </summary>
public sealed class EffectiveClaimsStoreTests
{
private readonly EffectiveClaimsStore _store;
public EffectiveClaimsStoreTests()
{
_store = new EffectiveClaimsStore(NullLogger<EffectiveClaimsStore>.Instance);
}
[Fact]
public void GetEffectiveClaims_NoClaimsRegistered_ReturnsEmpty()
{
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/test");
// Assert
claims.Should().BeEmpty();
}
[Fact]
public void GetEffectiveClaims_MicroserviceClaimsOnly_ReturnsMicroserviceClaims()
{
// Arrange
var endpoint = CreateEndpoint("GET", "/api/test", [
new ClaimRequirement { Type = "scope", Value = "read" }
]);
_store.UpdateFromMicroservice("test-service", [endpoint]);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/test");
// Assert
claims.Should().HaveCount(1);
claims[0].Type.Should().Be("scope");
claims[0].Value.Should().Be("read");
}
[Fact]
public void GetEffectiveClaims_AuthorityOverrideExists_ReturnsAuthorityClaims()
{
// Arrange
var endpoint = CreateEndpoint("GET", "/api/test", [
new ClaimRequirement { Type = "scope", Value = "read" }
]);
_store.UpdateFromMicroservice("test-service", [endpoint]);
var authorityOverrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("test-service", "GET", "/api/test")] = [
new ClaimRequirement { Type = "role", Value = "admin" }
]
};
_store.UpdateFromAuthority(authorityOverrides);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/test");
// Assert
claims.Should().HaveCount(1);
claims[0].Type.Should().Be("role");
claims[0].Value.Should().Be("admin");
}
[Fact]
public void GetEffectiveClaims_AuthorityTakesPrecedence_OverMicroservice()
{
// Arrange - microservice claims with different requirements
var endpoint = CreateEndpoint("POST", "/api/users", [
new ClaimRequirement { Type = "scope", Value = "users:read" },
new ClaimRequirement { Type = "role", Value = "user" }
]);
_store.UpdateFromMicroservice("user-service", [endpoint]);
// Authority overrides with stricter requirements
var authorityOverrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("user-service", "POST", "/api/users")] = [
new ClaimRequirement { Type = "scope", Value = "users:write" },
new ClaimRequirement { Type = "role", Value = "admin" },
new ClaimRequirement { Type = "department", Value = "IT" }
]
};
_store.UpdateFromAuthority(authorityOverrides);
// Act
var claims = _store.GetEffectiveClaims("user-service", "POST", "/api/users");
// Assert - Authority claims completely replace microservice claims
claims.Should().HaveCount(3);
claims.Should().Contain(c => c.Type == "scope" && c.Value == "users:write");
claims.Should().Contain(c => c.Type == "role" && c.Value == "admin");
claims.Should().Contain(c => c.Type == "department" && c.Value == "IT");
claims.Should().NotContain(c => c.Value == "users:read");
claims.Should().NotContain(c => c.Value == "user");
}
[Fact]
public void GetEffectiveClaims_EndpointWithoutAuthority_FallsBackToMicroservice()
{
// Arrange
var endpoints = new[]
{
CreateEndpoint("GET", "/api/public", [
new ClaimRequirement { Type = "scope", Value = "public" }
]),
CreateEndpoint("GET", "/api/private", [
new ClaimRequirement { Type = "scope", Value = "private" }
])
};
_store.UpdateFromMicroservice("test-service", endpoints);
// Authority only overrides /api/private
var authorityOverrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("test-service", "GET", "/api/private")] = [
new ClaimRequirement { Type = "role", Value = "admin" }
]
};
_store.UpdateFromAuthority(authorityOverrides);
// Act
var publicClaims = _store.GetEffectiveClaims("test-service", "GET", "/api/public");
var privateClaims = _store.GetEffectiveClaims("test-service", "GET", "/api/private");
// Assert
publicClaims.Should().HaveCount(1);
publicClaims[0].Type.Should().Be("scope");
publicClaims[0].Value.Should().Be("public");
privateClaims.Should().HaveCount(1);
privateClaims[0].Type.Should().Be("role");
privateClaims[0].Value.Should().Be("admin");
}
[Fact]
public void UpdateFromAuthority_ClearsPreviousAuthorityOverrides()
{
// Arrange - first Authority update
var firstOverrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("svc", "GET", "/first")] = [
new ClaimRequirement { Type = "claim1", Value = "value1" }
]
};
_store.UpdateFromAuthority(firstOverrides);
// Second Authority update (different endpoint)
var secondOverrides = new Dictionary<EndpointKey, IReadOnlyList<ClaimRequirement>>
{
[EndpointKey.Create("svc", "GET", "/second")] = [
new ClaimRequirement { Type = "claim2", Value = "value2" }
]
};
_store.UpdateFromAuthority(secondOverrides);
// Act
var firstClaims = _store.GetEffectiveClaims("svc", "GET", "/first");
var secondClaims = _store.GetEffectiveClaims("svc", "GET", "/second");
// Assert - first override should be gone
firstClaims.Should().BeEmpty();
secondClaims.Should().HaveCount(1);
secondClaims[0].Type.Should().Be("claim2");
}
[Fact]
public void UpdateFromMicroservice_EmptyClaims_RemovesFromStore()
{
// Arrange - first register claims
var endpoint = CreateEndpoint("GET", "/api/test", [
new ClaimRequirement { Type = "scope", Value = "read" }
]);
_store.UpdateFromMicroservice("test-service", [endpoint]);
// Then update with empty claims
var emptyEndpoint = CreateEndpoint("GET", "/api/test", []);
_store.UpdateFromMicroservice("test-service", [emptyEndpoint]);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/test");
// Assert
claims.Should().BeEmpty();
}
[Fact]
public void RemoveService_RemovesAllMicroserviceClaimsForService()
{
// Arrange
var endpoints = new[]
{
CreateEndpoint("GET", "/api/a", [new ClaimRequirement { Type = "scope", Value = "a" }]),
CreateEndpoint("GET", "/api/b", [new ClaimRequirement { Type = "scope", Value = "b" }])
};
_store.UpdateFromMicroservice("service-to-remove", endpoints);
var otherEndpoint = CreateEndpoint("GET", "/api/other", [
new ClaimRequirement { Type = "scope", Value = "other" }
]);
_store.UpdateFromMicroservice("other-service", [otherEndpoint]);
// Act
_store.RemoveService("service-to-remove");
// Assert
_store.GetEffectiveClaims("service-to-remove", "GET", "/api/a").Should().BeEmpty();
_store.GetEffectiveClaims("service-to-remove", "GET", "/api/b").Should().BeEmpty();
_store.GetEffectiveClaims("other-service", "GET", "/api/other").Should().HaveCount(1);
}
[Fact]
public void GetEffectiveClaims_CaseInsensitiveServiceAndPath()
{
// Arrange
var endpoint = CreateEndpoint("GET", "/API/Test", [
new ClaimRequirement { Type = "scope", Value = "read" }
]);
_store.UpdateFromMicroservice("Test-Service", [endpoint]);
// Act - query with different case
var claims = _store.GetEffectiveClaims("TEST-SERVICE", "get", "/api/test");
// Assert
claims.Should().HaveCount(1);
claims[0].Type.Should().Be("scope");
}
[Fact]
public void GetEffectiveClaims_ClaimWithNullValue_Matches()
{
// Arrange - claim that only requires type, any value
var endpoint = CreateEndpoint("GET", "/api/test", [
new ClaimRequirement { Type = "authenticated", Value = null }
]);
_store.UpdateFromMicroservice("test-service", [endpoint]);
// Act
var claims = _store.GetEffectiveClaims("test-service", "GET", "/api/test");
// Assert
claims.Should().HaveCount(1);
claims[0].Type.Should().Be("authenticated");
claims[0].Value.Should().BeNull();
}
private static EndpointDescriptor CreateEndpoint(
string method,
string path,
List<ClaimRequirement> claims)
{
return new EndpointDescriptor
{
ServiceName = "test-service",
Version = "1.0.0",
Method = method,
Path = path,
RequiringClaims = claims
};
}
}

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,29 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Gateway.WebService.Tests;
public class GatewayHealthTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public GatewayHealthTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task HealthEndpoint_ReturnsOk()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
response.EnsureSuccessStatusCode();
}
}

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,215 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using GatewayClaimsStore = StellaOps.Gateway.WebService.Authorization.IEffectiveClaimsStore;
using StellaOps.Gateway.WebService.Services;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Messaging;
using StellaOps.Router.Transport.Messaging.Options;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
namespace StellaOps.Gateway.WebService.Tests.Integration;
/// <summary>
/// Unit tests for the messaging transport integration in GatewayHostedService and GatewayTransportClient.
/// These tests verify the wiring and event handling without requiring a real Valkey instance.
/// </summary>
public sealed class MessagingTransportIntegrationTests
{
private readonly JsonSerializerOptions _jsonOptions;
public MessagingTransportIntegrationTests()
{
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
[Fact]
public void GatewayHostedService_CanAcceptMessagingServer()
{
// Arrange
var mockQueueFactory = new Mock<IMessageQueueFactory>();
var messagingOptions = Options.Create(new MessagingTransportOptions());
var messagingServer = new MessagingTransportServer(
mockQueueFactory.Object,
messagingOptions,
NullLogger<MessagingTransportServer>.Instance);
var gatewayOptions = Options.Create(new GatewayOptions());
var routingState = new Mock<IGlobalRoutingState>();
var claimsStore = new Mock<GatewayClaimsStore>();
var tcpOptions = Options.Create(new TcpTransportOptions { Port = 29100 });
var tlsOptions = Options.Create(new TlsTransportOptions { Port = 29443 });
var tcpServer = new TcpTransportServer(tcpOptions, NullLogger<TcpTransportServer>.Instance);
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
var transportClient = new GatewayTransportClient(
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer);
// Act & Assert - construction should succeed with messaging server
var hostedService = new GatewayHostedService(
tcpServer,
tlsServer,
routingState.Object,
transportClient,
claimsStore.Object,
gatewayOptions,
new GatewayServiceStatus(),
NullLogger<GatewayHostedService>.Instance,
openApiCache: null,
messagingServer: messagingServer);
Assert.NotNull(hostedService);
}
[Fact]
public void GatewayHostedService_CanAcceptNullMessagingServer()
{
// Arrange
var gatewayOptions = Options.Create(new GatewayOptions());
var routingState = new Mock<IGlobalRoutingState>();
var claimsStore = new Mock<GatewayClaimsStore>();
var tcpOptions = Options.Create(new TcpTransportOptions { Port = 29101 });
var tlsOptions = Options.Create(new TlsTransportOptions { Port = 29444 });
var tcpServer = new TcpTransportServer(tcpOptions, NullLogger<TcpTransportServer>.Instance);
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
var transportClient = new GatewayTransportClient(
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer: null);
// Act & Assert - construction should succeed without messaging server
var hostedService = new GatewayHostedService(
tcpServer,
tlsServer,
routingState.Object,
transportClient,
claimsStore.Object,
gatewayOptions,
new GatewayServiceStatus(),
NullLogger<GatewayHostedService>.Instance,
openApiCache: null,
messagingServer: null);
Assert.NotNull(hostedService);
}
[Fact]
public void GatewayTransportClient_WithMessagingServer_CanBeConstructed()
{
// Arrange
var mockQueueFactory = new Mock<IMessageQueueFactory>();
var messagingOptions = Options.Create(new MessagingTransportOptions());
var messagingServer = new MessagingTransportServer(
mockQueueFactory.Object,
messagingOptions,
NullLogger<MessagingTransportServer>.Instance);
var tcpOptions = Options.Create(new TcpTransportOptions { Port = 29102 });
var tlsOptions = Options.Create(new TlsTransportOptions { Port = 29445 });
var tcpServer = new TcpTransportServer(tcpOptions, NullLogger<TcpTransportServer>.Instance);
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
// Act
var transportClient = new GatewayTransportClient(
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer);
// Assert
Assert.NotNull(transportClient);
}
[Fact]
public async Task GatewayTransportClient_SendToMessagingConnection_ThrowsWhenServerNull()
{
// Arrange
var tcpOptions = Options.Create(new TcpTransportOptions { Port = 29103 });
var tlsOptions = Options.Create(new TlsTransportOptions { Port = 29446 });
var tcpServer = new TcpTransportServer(tcpOptions, NullLogger<TcpTransportServer>.Instance);
var tlsServer = new TlsTransportServer(tlsOptions, NullLogger<TlsTransportServer>.Instance);
// No messaging server provided
var transportClient = new GatewayTransportClient(
tcpServer,
tlsServer,
NullLogger<GatewayTransportClient>.Instance,
messagingServer: null);
var connection = new ConnectionState
{
ConnectionId = "msg-conn-001",
Instance = new InstanceDescriptor
{
InstanceId = "test-001",
ServiceName = "test-service",
Version = "1.0.0",
Region = "test"
},
TransportType = TransportType.Messaging
};
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = Guid.NewGuid().ToString("N"),
Payload = new byte[] { 1, 2, 3 }
};
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await transportClient.SendRequestAsync(connection, frame, TimeSpan.FromSeconds(5), CancellationToken.None));
}
[Fact]
public void GatewayOptions_MessagingTransport_HasCorrectDefaults()
{
// Arrange & Act
var options = new GatewayMessagingTransportOptions();
// Assert
Assert.False(options.Enabled);
Assert.Equal("localhost:6379", options.ConnectionString);
Assert.Null(options.Database);
Assert.Equal("router:requests:{service}", options.RequestQueueTemplate);
Assert.Equal("router:responses", options.ResponseQueueName);
Assert.Equal("router-gateway", options.ConsumerGroup);
Assert.Equal("30s", options.RequestTimeout);
Assert.Equal("5m", options.LeaseDuration);
Assert.Equal(10, options.BatchSize);
Assert.Equal("10s", options.HeartbeatInterval);
}
[Fact]
public void GatewayTransportOptions_IncludesMessaging()
{
// Arrange & Act
var options = new GatewayTransportOptions();
// Assert
Assert.NotNull(options.Tcp);
Assert.NotNull(options.Tls);
Assert.NotNull(options.Messaging);
}
}

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,502 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Gateway.WebService.Middleware;
namespace StellaOps.Gateway.WebService.Tests.Middleware;
/// <summary>
/// Unit tests for <see cref="IdentityHeaderPolicyMiddleware"/>.
/// Verifies that:
/// 1. Reserved identity headers are stripped from incoming requests
/// 2. Headers are overwritten from validated claims (not "set-if-missing")
/// 3. Client-provided headers cannot spoof identity
/// 4. Canonical and legacy headers are written correctly
/// </summary>
public sealed class IdentityHeaderPolicyMiddlewareTests
{
private readonly IdentityHeaderPolicyOptions _options;
private bool _nextCalled;
public IdentityHeaderPolicyMiddlewareTests()
{
_options = new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = true,
AllowScopeHeaderOverride = false
};
_nextCalled = false;
}
private IdentityHeaderPolicyMiddleware CreateMiddleware()
{
_nextCalled = false;
return new IdentityHeaderPolicyMiddleware(
_ =>
{
_nextCalled = true;
return Task.CompletedTask;
},
NullLogger<IdentityHeaderPolicyMiddleware>.Instance,
_options);
}
#region Reserved Header Stripping
[Fact]
public async Task InvokeAsync_StripsAllReservedStellaOpsHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof identity headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
context.Request.Headers["X-StellaOps-Project"] = "spoofed-project";
context.Request.Headers["X-StellaOps-Actor"] = "spoofed-actor";
context.Request.Headers["X-StellaOps-Scopes"] = "admin superuser";
context.Request.Headers["X-StellaOps-Client"] = "spoofed-client";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Spoofed values should be replaced with anonymous identity values
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys); // No tenant for anonymous
Assert.DoesNotContain("X-StellaOps-Project", context.Request.Headers.Keys); // No project for anonymous
// Actor is overwritten with "anonymous", not spoofed value
Assert.Equal("anonymous", context.Request.Headers["X-StellaOps-Actor"].ToString());
// Spoofed scopes are replaced with empty scopes for anonymous
Assert.Equal(string.Empty, context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
[Fact]
public async Task InvokeAsync_StripsAllReservedLegacyHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof legacy headers
context.Request.Headers["X-Stella-Tenant"] = "spoofed-tenant";
context.Request.Headers["X-Stella-Project"] = "spoofed-project";
context.Request.Headers["X-Stella-Actor"] = "spoofed-actor";
context.Request.Headers["X-Stella-Scopes"] = "admin";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Spoofed values should be replaced with anonymous identity values
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys); // No tenant for anonymous
Assert.DoesNotContain("X-Stella-Project", context.Request.Headers.Keys); // No project for anonymous
// Actor is overwritten with "anonymous" (legacy headers enabled by default)
Assert.Equal("anonymous", context.Request.Headers["X-Stella-Actor"].ToString());
// Spoofed scopes are replaced with empty scopes for anonymous
Assert.Equal(string.Empty, context.Request.Headers["X-Stella-Scopes"].ToString());
}
[Fact]
public async Task InvokeAsync_StripsRawClaimHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof raw claim headers
context.Request.Headers["sub"] = "spoofed-subject";
context.Request.Headers["tid"] = "spoofed-tenant";
context.Request.Headers["scope"] = "admin superuser";
context.Request.Headers["scp"] = "admin";
context.Request.Headers["cnf"] = "{\"jkt\":\"spoofed-thumbprint\"}";
context.Request.Headers["cnf.jkt"] = "spoofed-thumbprint";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Raw claim headers should be stripped
Assert.DoesNotContain("sub", context.Request.Headers.Keys);
Assert.DoesNotContain("tid", context.Request.Headers.Keys);
Assert.DoesNotContain("scope", context.Request.Headers.Keys);
Assert.DoesNotContain("scp", context.Request.Headers.Keys);
Assert.DoesNotContain("cnf", context.Request.Headers.Keys);
Assert.DoesNotContain("cnf.jkt", context.Request.Headers.Keys);
}
#endregion
#region Header Overwriting (Not Set-If-Missing)
[Fact]
public async Task InvokeAsync_OverwritesSpoofedTenantWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Tenant, "real-tenant"),
new Claim(StellaOpsClaimTypes.Subject, "real-subject")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof tenant
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Header should contain claim value, not spoofed value
Assert.Equal("real-tenant", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_OverwritesSpoofedActorWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "real-actor")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof actor
context.Request.Headers["X-StellaOps-Actor"] = "spoofed-actor";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("real-actor", context.Request.Headers["X-StellaOps-Actor"].ToString());
}
[Fact]
public async Task InvokeAsync_OverwritesSpoofedScopesWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "read write")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof scopes
context.Request.Headers["X-StellaOps-Scopes"] = "admin superuser delete-all";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Should contain actual scopes, not spoofed scopes
var actualScopes = context.Request.Headers["X-StellaOps-Scopes"].ToString();
Assert.Contains("read", actualScopes);
Assert.Contains("write", actualScopes);
Assert.DoesNotContain("admin", actualScopes);
Assert.DoesNotContain("superuser", actualScopes);
Assert.DoesNotContain("delete-all", actualScopes);
}
#endregion
#region Claim Extraction
[Fact]
public async Task InvokeAsync_ExtractsSubjectFromSubClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.Equal("user-123", context.Items[GatewayContextKeys.Actor]);
}
[Fact]
public async Task InvokeAsync_ExtractsTenantFromStellaOpsTenantClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-abc", context.Items[GatewayContextKeys.TenantId]);
}
[Fact]
public async Task InvokeAsync_ExtractsTenantFromTidClaimAsFallback()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("tid", "legacy-tenant-456")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_ExtractsScopesFromSpaceSeparatedScopeClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "read write delete")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("read", scopes);
Assert.Contains("write", scopes);
Assert.Contains("delete", scopes);
}
[Fact]
public async Task InvokeAsync_ExtractsScopesFromIndividualScpClaims()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.ScopeItem, "read"),
new Claim(StellaOpsClaimTypes.ScopeItem, "write"),
new Claim(StellaOpsClaimTypes.ScopeItem, "admin")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("read", scopes);
Assert.Contains("write", scopes);
Assert.Contains("admin", scopes);
}
[Fact]
public async Task InvokeAsync_ScopesAreSortedDeterministically()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.ScopeItem, "zebra"),
new Claim(StellaOpsClaimTypes.ScopeItem, "apple"),
new Claim(StellaOpsClaimTypes.ScopeItem, "mango")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("apple mango zebra", context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
#endregion
#region Legacy Header Compatibility
[Fact]
public async Task InvokeAsync_WritesLegacyHeadersWhenEnabled()
{
_options.EnableLegacyHeaders = true;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc"),
new Claim(StellaOpsClaimTypes.Scope, "read write")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Both canonical and legacy headers should be present
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.Equal("user-123", context.Request.Headers["X-Stella-Actor"].ToString());
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-abc", context.Request.Headers["X-Stella-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_OmitsLegacyHeadersWhenDisabled()
{
_options.EnableLegacyHeaders = false;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Only canonical headers should be present
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.DoesNotContain("X-Stella-Actor", context.Request.Headers.Keys);
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys);
}
#endregion
#region Anonymous Identity
[Fact]
public async Task InvokeAsync_UnauthenticatedRequest_SetsAnonymousIdentity()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.True((bool)context.Items[GatewayContextKeys.IsAnonymous]!);
Assert.Equal("anonymous", context.Items[GatewayContextKeys.Actor]);
}
[Fact]
public async Task InvokeAsync_AuthenticatedRequest_SetsIsAnonymousFalse()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.False((bool)context.Items[GatewayContextKeys.IsAnonymous]!);
}
[Fact]
public async Task InvokeAsync_AnonymousRequest_WritesEmptyScopes()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal(string.Empty, context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
#endregion
#region DPoP Thumbprint
[Fact]
public async Task InvokeAsync_ExtractsDpopThumbprintFromCnfClaim()
{
var middleware = CreateMiddleware();
const string jkt = "SHA256-thumbprint-abc123";
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("cnf", $"{{\"jkt\":\"{jkt}\"}}")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal(jkt, context.Request.Headers["cnf.jkt"].ToString());
Assert.Equal(jkt, context.Items[GatewayContextKeys.DpopThumbprint]);
}
[Fact]
public async Task InvokeAsync_InvalidCnfJson_DoesNotThrow()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("cnf", "not-valid-json")
};
var context = CreateHttpContext("/api/scan", claims);
var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context));
Assert.Null(exception);
Assert.True(_nextCalled);
Assert.DoesNotContain("cnf.jkt", context.Request.Headers.Keys);
}
#endregion
#region System Path Bypass
[Theory]
[InlineData("/health")]
[InlineData("/health/ready")]
[InlineData("/metrics")]
[InlineData("/openapi.json")]
[InlineData("/openapi.yaml")]
public async Task InvokeAsync_SystemPath_SkipsProcessing(string path)
{
var middleware = CreateMiddleware();
var context = CreateHttpContext(path);
// Add spoofed headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// System paths skip processing, so spoofed headers remain (not stripped)
Assert.Equal("spoofed", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Theory]
[InlineData("/api/scan")]
[InlineData("/api/v1/sbom")]
[InlineData("/jobs")]
public async Task InvokeAsync_NonSystemPath_ProcessesHeaders(string path)
{
var middleware = CreateMiddleware();
var context = CreateHttpContext(path);
// Add spoofed headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Non-system paths strip spoofed headers
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys);
}
#endregion
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,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,42 @@
<?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>
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Gateway.WebService.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Using Include="Moq" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</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" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

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