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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

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