wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TenantIsolationTests.cs
|
||||
// Sprint: POL-TEN-05 - Tenant Isolation Tests
|
||||
// Description: Focused unit tests for tenant isolation in the Policy Engine's
|
||||
// TenantContextMiddleware, TenantContextAccessor, and
|
||||
// TenantContextEndpointFilter.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Tenancy;
|
||||
using Xunit;
|
||||
|
||||
using MsOptions = Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Tests verifying tenant isolation behaviour of the Policy Engine's own
|
||||
/// tenancy middleware and endpoint filter. These are pure unit tests --
|
||||
/// no Postgres, no WebApplicationFactory.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TenantIsolationTests
|
||||
{
|
||||
private readonly TenantContextAccessor _accessor = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 2, 23, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 1. Canonical claim resolution
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_ResolvesCanonicalStellaTenantClaim_WhenHeaderAbsent()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? captured = null;
|
||||
var middleware = BuildMiddleware(
|
||||
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
|
||||
requireHeader: true);
|
||||
|
||||
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(TenantContextConstants.CanonicalTenantClaim, "acme-corp"),
|
||||
new Claim("sub", "user-1")
|
||||
},
|
||||
"TestAuth"));
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(ctx, _accessor);
|
||||
|
||||
// Assert
|
||||
captured.Should().NotBeNull("middleware should resolve tenant from canonical claim");
|
||||
captured!.TenantId.Should().Be("acme-corp");
|
||||
ctx.Response.StatusCode.Should().NotBe(StatusCodes.Status400BadRequest);
|
||||
_accessor.TenantContext.Should().BeNull("accessor is cleared after pipeline completes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 2. Missing tenant produces null context (not-required mode)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_MissingTenantAndNoClaimsWithRequireDisabled_DefaultsTenantAndContextIsSet()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? captured = null;
|
||||
var middleware = BuildMiddleware(
|
||||
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
|
||||
requireHeader: false);
|
||||
|
||||
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
|
||||
// No claims, no header
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(ctx, _accessor);
|
||||
|
||||
// Assert
|
||||
captured.Should().NotBeNull("with RequireTenantHeader=false, middleware defaults to public tenant");
|
||||
captured!.TenantId.Should().Be(TenantContextConstants.DefaultTenantId,
|
||||
"when no header/claim is present and tenant is not required, the default tenant 'public' is used");
|
||||
_accessor.TenantContext.Should().BeNull("accessor is cleared after pipeline completes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_MissingTenantAndNoClaimsWithRequireEnabled_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var nextCalled = false;
|
||||
var middleware = BuildMiddleware(
|
||||
next: _ => { nextCalled = true; return Task.CompletedTask; },
|
||||
requireHeader: true);
|
||||
|
||||
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
|
||||
// No claims, no header
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(ctx, _accessor);
|
||||
|
||||
// Assert
|
||||
nextCalled.Should().BeFalse("pipeline should be short-circuited");
|
||||
ctx.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
_accessor.TenantContext.Should().BeNull("no context should be set on failure");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Legacy "tid" claim fallback
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_FallsBackToLegacyTidClaim_WhenHeaderAndCanonicalClaimAbsent()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? captured = null;
|
||||
var middleware = BuildMiddleware(
|
||||
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
|
||||
requireHeader: true);
|
||||
|
||||
var ctx = CreateHttpContext("/api/policy/risk-profiles", tenantId: null);
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(TenantContextConstants.LegacyTenantClaim, "legacy-tenant-42"),
|
||||
new Claim("sub", "svc-account")
|
||||
},
|
||||
"TestAuth"));
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(ctx, _accessor);
|
||||
|
||||
// Assert
|
||||
captured.Should().NotBeNull("middleware should fall back to legacy tid claim");
|
||||
captured!.TenantId.Should().Be("legacy-tenant-42");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 4. TenantContextEndpointFilter rejects tenantless requests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointFilter_RejectsTenantlessRequest_Returns400WithErrorCode()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new TenantContextEndpointFilter();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITenantContextAccessor>(_accessor);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var httpContext = new DefaultHttpContext { RequestServices = sp };
|
||||
httpContext.Response.Body = new MemoryStream();
|
||||
// Deliberately do NOT set _accessor.TenantContext
|
||||
|
||||
var filterContext = CreateEndpointFilterContext(httpContext);
|
||||
|
||||
// Act
|
||||
var result = await filter.InvokeAsync(filterContext, _ =>
|
||||
new ValueTask<object?>(Results.Ok("should not reach")));
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
|
||||
// The filter returns a ProblemDetails result (IResult).
|
||||
// Verify by writing it to the response and checking status code.
|
||||
if (result is IResult httpResult)
|
||||
{
|
||||
await httpResult.ExecuteAsync(httpContext);
|
||||
httpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 5. Header takes precedence over claims (no conflict detection
|
||||
// in middleware -- header wins, which is the correct design)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_HeaderTakesPrecedenceOverClaim_WhenBothPresent()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? captured = null;
|
||||
var middleware = BuildMiddleware(
|
||||
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
|
||||
requireHeader: true);
|
||||
|
||||
var ctx = CreateHttpContext("/api/policy/risk-profiles", tenantId: "header-tenant");
|
||||
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(TenantContextConstants.CanonicalTenantClaim, "claim-tenant"),
|
||||
new Claim(TenantContextConstants.LegacyTenantClaim, "legacy-tenant"),
|
||||
new Claim("sub", "user-1")
|
||||
},
|
||||
"TestAuth"));
|
||||
|
||||
// Act
|
||||
await middleware.InvokeAsync(ctx, _accessor);
|
||||
|
||||
// Assert
|
||||
captured.Should().NotBeNull();
|
||||
captured!.TenantId.Should().Be("header-tenant",
|
||||
"the X-Stella-Tenant header must take precedence over JWT claims " +
|
||||
"so that gateway-injected headers are authoritative");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="TenantContextMiddleware"/> with configurable options.
|
||||
/// </summary>
|
||||
private TenantContextMiddleware BuildMiddleware(
|
||||
RequestDelegate next,
|
||||
bool requireHeader = true,
|
||||
bool enabled = true)
|
||||
{
|
||||
var options = new TenantContextOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
RequireTenantHeader = requireHeader,
|
||||
ExcludedPaths = ["/healthz", "/readyz"]
|
||||
};
|
||||
|
||||
return new TenantContextMiddleware(
|
||||
next,
|
||||
MsOptions.Options.Create(options),
|
||||
NullLogger<TenantContextMiddleware>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <see cref="DefaultHttpContext"/> for middleware tests.
|
||||
/// </summary>
|
||||
private static DefaultHttpContext CreateHttpContext(string path, string? tenantId)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Path = path;
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
ctx.Request.Headers[TenantContextConstants.TenantHeader] = tenantId;
|
||||
}
|
||||
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <see cref="EndpointFilterInvocationContext"/> for filter tests.
|
||||
/// </summary>
|
||||
private static EndpointFilterInvocationContext CreateEndpointFilterContext(HttpContext httpContext)
|
||||
{
|
||||
// EndpointFilterInvocationContext is abstract; use the default implementation
|
||||
// via the factory available on DefaultEndpointFilterInvocationContext.
|
||||
return new DefaultEndpointFilterInvocationContext(httpContext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user