599 lines
18 KiB
C#
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);
|
|
}
|
|
}
|