using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; 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; public sealed class TenantContextTests { [Fact] public void TenantContext_ForTenant_CreatesTenantContext() { // Arrange & Act var context = TenantContext.ForTenant("tenant-123", "project-456", canWrite: true, actorId: "user@example.com"); // Assert Assert.Equal("tenant-123", context.TenantId); Assert.Equal("project-456", context.ProjectId); Assert.True(context.CanWrite); Assert.Equal("user@example.com", context.ActorId); } [Fact] public void TenantContext_ForTenant_WithoutOptionalFields_CreatesTenantContext() { // Act var context = TenantContext.ForTenant("tenant-123"); // Assert Assert.Equal("tenant-123", context.TenantId); Assert.Null(context.ProjectId); Assert.False(context.CanWrite); Assert.Null(context.ActorId); } [Fact] public void TenantContext_ForTenant_ThrowsOnNullTenantId() { // Act & Assert Assert.Throws(() => TenantContext.ForTenant(null!)); } [Fact] public void TenantContext_ForTenant_ThrowsOnEmptyTenantId() { // Act & Assert Assert.Throws(() => TenantContext.ForTenant(string.Empty)); } [Fact] public void TenantContext_ForTenant_ThrowsOnWhitespaceTenantId() { // Act & Assert Assert.Throws(() => TenantContext.ForTenant(" ")); } } public sealed class TenantContextAccessorTests { [Fact] public void TenantContextAccessor_GetSet_WorksCorrectly() { // Arrange var accessor = new TenantContextAccessor(); var context = TenantContext.ForTenant("tenant-123"); // Act accessor.TenantContext = context; // Assert Assert.NotNull(accessor.TenantContext); Assert.Equal("tenant-123", accessor.TenantContext.TenantId); } [Fact] public void TenantContextAccessor_InitialValue_IsNull() { // Arrange & Act var accessor = new TenantContextAccessor(); // Assert Assert.Null(accessor.TenantContext); } [Fact] public void TenantContextAccessor_SetNull_ClearsContext() { // Arrange var accessor = new TenantContextAccessor(); accessor.TenantContext = TenantContext.ForTenant("tenant-123"); // Act accessor.TenantContext = null; // Assert Assert.Null(accessor.TenantContext); } } public sealed class TenantValidationResultTests { [Fact] public void TenantValidationResult_Success_CreatesValidResult() { // Arrange var context = TenantContext.ForTenant("tenant-123"); // Act var result = TenantValidationResult.Success(context); // Assert Assert.True(result.IsValid); Assert.Null(result.ErrorCode); Assert.Null(result.ErrorMessage); Assert.NotNull(result.Context); Assert.Equal("tenant-123", result.Context.TenantId); } [Fact] public void TenantValidationResult_Failure_CreatesInvalidResult() { // Act var result = TenantValidationResult.Failure("ERR_CODE", "Error message"); // Assert Assert.False(result.IsValid); Assert.Equal("ERR_CODE", result.ErrorCode); Assert.Equal("Error message", result.ErrorMessage); Assert.Null(result.Context); } } public sealed class TenantContextMiddlewareTests { private readonly NullLogger _logger; private readonly TenantContextAccessor _tenantAccessor; private readonly TenantContextOptions _options; private readonly TimeProvider _timeProvider; public TenantContextMiddlewareTests() { _logger = NullLogger.Instance; _tenantAccessor = new TenantContextAccessor(); _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); _options = new TenantContextOptions { Enabled = true, RequireTenantHeader = true, ExcludedPaths = new List { "/healthz", "/readyz" } }; } [Fact] public async Task Middleware_WithValidTenantHeader_SetsTenantContext() { // Arrange TenantContext? capturedContext = null; var nextCalled = false; var middleware = new TenantContextMiddleware( _ => { nextCalled = true; capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123"); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.True(nextCalled); Assert.NotNull(capturedContext); Assert.Equal("tenant-123", capturedContext!.TenantId); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_WithTenantAndProjectHeaders_SetsBothInContext() { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123", "project-456"); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal("tenant-123", capturedContext!.TenantId); Assert.Equal("project-456", capturedContext.ProjectId); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_MissingTenantHeader_Returns400WithErrorCode() { // Arrange var nextCalled = false; var middleware = new TenantContextMiddleware( _ => { nextCalled = true; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", tenantId: null); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.False(nextCalled); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_MissingTenantHeaderNotRequired_UsesDefaultTenant() { // Arrange TenantContext? capturedContext = null; var optionsNotRequired = new TenantContextOptions { Enabled = true, RequireTenantHeader = false }; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(optionsNotRequired), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", tenantId: null); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal(TenantContextConstants.DefaultTenantId, capturedContext!.TenantId); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_ExcludedPath_SkipsValidation() { // Arrange var nextCalled = false; var middleware = new TenantContextMiddleware( _ => { nextCalled = true; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/healthz", tenantId: null); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.True(nextCalled); Assert.Null(_tenantAccessor.TenantContext); // Not set for excluded paths } [Fact] public async Task Middleware_Disabled_SkipsValidation() { // Arrange var disabledOptions = new TenantContextOptions { Enabled = false }; var nextCalled = false; var middleware = new TenantContextMiddleware( _ => { nextCalled = true; return Task.CompletedTask; }, MsOptions.Options.Create(disabledOptions), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", tenantId: null); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.True(nextCalled); } [Theory] [InlineData("tenant-123")] [InlineData("TENANT_456")] [InlineData("tenant_with-mixed-123")] public async Task Middleware_ValidTenantIdFormat_Passes(string tenantId) { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", tenantId); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal(tenantId, capturedContext!.TenantId); Assert.Null(_tenantAccessor.TenantContext); } [Theory] [InlineData("tenant 123")] // spaces [InlineData("tenant@123")] // special char [InlineData("tenant/123")] // slash [InlineData("tenant.123")] // dot public async Task Middleware_InvalidTenantIdFormat_Returns400(string tenantId) { // Arrange var nextCalled = false; var middleware = new TenantContextMiddleware( _ => { nextCalled = true; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", tenantId); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.False(nextCalled); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } [Fact] public async Task Middleware_TenantIdTooLong_Returns400() { // Arrange var longTenantId = new string('a', 300); // exceeds default 256 limit var middleware = new TenantContextMiddleware( _ => Task.CompletedTask, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", longTenantId); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } [Theory] [InlineData("project-123")] [InlineData("PROJECT_456")] [InlineData("proj_with-mixed-123")] public async Task Middleware_ValidProjectIdFormat_Passes(string projectId) { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123", projectId); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal(projectId, capturedContext!.ProjectId); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_WithWriteScope_SetsCanWriteTrue() { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123"); var claims = new[] { new Claim("sub", "user@example.com"), new Claim("scope", "policy:write") }; context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.True(capturedContext!.CanWrite); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_WithoutWriteScope_SetsCanWriteFalse() { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123"); var claims = new[] { new Claim("sub", "user@example.com"), new Claim("scope", "policy:read") }; context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.False(capturedContext!.CanWrite); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_ExtractsActorIdFromSubClaim() { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123"); var claims = new[] { new Claim("sub", "user-id-123") }; context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal("user-id-123", capturedContext!.ActorId); Assert.Null(_tenantAccessor.TenantContext); } [Fact] public async Task Middleware_ExtractsActorIdFromHeader() { // Arrange TenantContext? capturedContext = null; var middleware = new TenantContextMiddleware( _ => { capturedContext = _tenantAccessor.TenantContext; return Task.CompletedTask; }, MsOptions.Options.Create(_options), _logger, _timeProvider); var context = CreateHttpContext("/api/risk/profiles", "tenant-123"); context.Request.Headers["X-StellaOps-Actor"] = "service-account-123"; // Act await middleware.InvokeAsync(context, _tenantAccessor); // Assert Assert.NotNull(capturedContext); Assert.Equal("service-account-123", capturedContext!.ActorId); Assert.Null(_tenantAccessor.TenantContext); } private static DefaultHttpContext CreateHttpContext( string path, string? tenantId, string? projectId = null) { var context = new DefaultHttpContext(); context.Request.Path = path; if (!string.IsNullOrEmpty(tenantId)) { context.Request.Headers[TenantContextConstants.TenantHeader] = tenantId; } if (!string.IsNullOrEmpty(projectId)) { context.Request.Headers[TenantContextConstants.ProjectHeader] = projectId; } // Set up response body stream to capture output context.Response.Body = new MemoryStream(); return context; } } public sealed class TenantContextConstantsTests { [Fact] public void Constants_HaveExpectedValues() { Assert.Equal("X-Stella-Tenant", TenantContextConstants.TenantHeader); Assert.Equal("X-Stella-Project", TenantContextConstants.ProjectHeader); Assert.Equal("app.tenant_id", TenantContextConstants.TenantGuc); Assert.Equal("app.project_id", TenantContextConstants.ProjectGuc); Assert.Equal("app.can_write", TenantContextConstants.CanWriteGuc); Assert.Equal("public", TenantContextConstants.DefaultTenantId); Assert.Equal("POLICY_TENANT_HEADER_REQUIRED", TenantContextConstants.MissingTenantHeaderErrorCode); Assert.Equal("POLICY_TENANT_ID_INVALID", TenantContextConstants.InvalidTenantIdErrorCode); Assert.Equal("POLICY_TENANT_ACCESS_DENIED", TenantContextConstants.TenantAccessDeniedErrorCode); } } public sealed class TenantContextOptionsTests { [Fact] public void Options_HaveCorrectDefaults() { // Arrange & Act var options = new TenantContextOptions(); // Assert Assert.True(options.Enabled); Assert.True(options.RequireTenantHeader); Assert.Contains("/healthz", options.ExcludedPaths); Assert.Contains("/readyz", options.ExcludedPaths); Assert.Contains("/.well-known", options.ExcludedPaths); Assert.Equal(256, options.MaxTenantIdLength); Assert.Equal(256, options.MaxProjectIdLength); Assert.False(options.AllowMultiTenantQueries); } [Fact] public void SectionName_IsCorrect() { Assert.Equal("PolicyEngine:Tenancy", TenantContextOptions.SectionName); } }