Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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