Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Tenancy/TenantContextTests.cs

599 lines
18 KiB
C#

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<ArgumentNullException>(() => TenantContext.ForTenant(null!));
}
[Fact]
public void TenantContext_ForTenant_ThrowsOnEmptyTenantId()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => TenantContext.ForTenant(string.Empty));
}
[Fact]
public void TenantContext_ForTenant_ThrowsOnWhitespaceTenantId()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => 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<TenantContextMiddleware> _logger;
private readonly TenantContextAccessor _tenantAccessor;
private readonly TenantContextOptions _options;
private readonly TimeProvider _timeProvider;
public TenantContextMiddlewareTests()
{
_logger = NullLogger<TenantContextMiddleware>.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<string> { "/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);
}
}