Add post-quantum cryptography support with PqSoftCryptoProvider
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled

- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle.
- Added PqSoftProviderOptions and PqSoftKeyOptions for configuration.
- Created unit tests for Dilithium3 and Falcon512 signing and verification.
- Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists.
- Added KcmvpHashOnlyProvider for KCMVP baseline compliance.
- Updated project files and dependencies for new libraries and testing frameworks.
This commit is contained in:
StellaOps Bot
2025-12-07 15:04:19 +02:00
parent 862bb6ed80
commit 98e6b76584
119 changed files with 11436 additions and 1732 deletions

View File

@@ -0,0 +1,481 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Notifications;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
using Xunit;
using MsOptions = Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tests.Notifications;
public sealed class PolicyProfileNotificationServiceTests
{
private readonly FakeNotificationPublisher _publisher;
private readonly PolicyProfileNotificationFactory _factory;
private readonly PolicyProfileNotificationOptions _options;
private readonly PolicyProfileNotificationService _service;
private readonly FakeTimeProvider _timeProvider;
public PolicyProfileNotificationServiceTests()
{
_publisher = new FakeNotificationPublisher();
_timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-07T12:00:00Z"));
_options = new PolicyProfileNotificationOptions
{
Enabled = true,
TopicName = "test.policy.profiles",
BaseUrl = "https://policy.test.local"
};
_factory = new PolicyProfileNotificationFactory(_timeProvider, _options);
_service = new PolicyProfileNotificationService(
_publisher,
_factory,
MsOptions.Options.Create(_options),
NullLogger<PolicyProfileNotificationService>.Instance);
}
[Fact]
public async Task NotifyProfileCreatedAsync_PublishesEvent()
{
// Arrange
var profile = CreateTestProfile();
// Act
await _service.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileCreated, evt.EventType);
Assert.Equal("tenant-123", evt.TenantId);
Assert.Equal("test-profile", evt.ProfileId);
Assert.Equal("1.0.0", evt.ProfileVersion);
Assert.NotNull(evt.Actor);
Assert.Equal("user", evt.Actor.Type);
Assert.Equal("alice@example.com", evt.Actor.Id);
Assert.NotNull(evt.Hash);
Assert.Equal("abc123hash", evt.Hash.Value);
}
[Fact]
public async Task NotifyProfileActivatedAsync_PublishesEvent()
{
// Arrange
var profile = CreateTestProfile();
// Act
await _service.NotifyProfileActivatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileActivated, evt.EventType);
Assert.Equal("tenant-123", evt.TenantId);
Assert.Equal("test-profile", evt.ProfileId);
}
[Fact]
public async Task NotifyProfileDeactivatedAsync_PublishesEvent()
{
// Act
await _service.NotifyProfileDeactivatedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"Deprecated in favor of v2.0.0",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileDeactivated, evt.EventType);
Assert.Equal("Deprecated in favor of v2.0.0", evt.ChangeReason);
}
[Fact]
public async Task NotifyThresholdChangedAsync_PublishesEventWithThresholds()
{
// Arrange
var profile = CreateTestProfileWithThresholds();
// Act
await _service.NotifyThresholdChangedAsync(
"tenant-123",
profile,
"alice@example.com",
"Increased high/critical thresholds",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ThresholdChanged, evt.EventType);
Assert.NotNull(evt.Thresholds);
}
[Fact]
public async Task NotifyOverrideAddedAsync_PublishesEventWithDetails()
{
// Act
await _service.NotifyOverrideAddedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"override-001",
"severity",
"CVE-2024-1234",
"suppress",
"False positive confirmed by security team",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.OverrideAdded, evt.EventType);
Assert.NotNull(evt.OverrideDetails);
Assert.Equal("override-001", evt.OverrideDetails.OverrideId);
Assert.Equal("severity", evt.OverrideDetails.OverrideType);
Assert.Equal("CVE-2024-1234", evt.OverrideDetails.Target);
Assert.Equal("False positive confirmed by security team", evt.OverrideDetails.Justification);
}
[Fact]
public async Task NotifyOverrideRemovedAsync_PublishesEvent()
{
// Act
await _service.NotifyOverrideRemovedAsync(
"tenant-123",
"test-profile",
"1.0.0",
"alice@example.com",
"override-001",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.OverrideRemoved, evt.EventType);
Assert.NotNull(evt.OverrideDetails);
Assert.Equal("override-001", evt.OverrideDetails.OverrideId);
}
[Fact]
public async Task NotifySimulationReadyAsync_PublishesEventWithDetails()
{
// Act
await _service.NotifySimulationReadyAsync(
"tenant-123",
"test-profile",
"1.0.0",
"sim-001",
findingsCount: 42,
highImpactCount: 5,
completedAt: _timeProvider.GetUtcNow(),
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.SimulationReady, evt.EventType);
Assert.NotNull(evt.SimulationDetails);
Assert.Equal("sim-001", evt.SimulationDetails.SimulationId);
Assert.Equal(42, evt.SimulationDetails.FindingsCount);
Assert.Equal(5, evt.SimulationDetails.HighImpactCount);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Created_PublishesNotification()
{
// Arrange
var profile = CreateTestProfile();
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-001",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Created,
OldStatus: null,
NewStatus: RiskProfileLifecycleStatus.Draft,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: null);
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileCreated, evt.EventType);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Activated_PublishesNotification()
{
// Arrange
var profile = CreateTestProfile();
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-002",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Activated,
OldStatus: RiskProfileLifecycleStatus.Draft,
NewStatus: RiskProfileLifecycleStatus.Active,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: null);
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileActivated, evt.EventType);
}
[Fact]
public async Task NotifyFromLifecycleEventAsync_Deprecated_PublishesDeactivatedNotification()
{
// Arrange
var lifecycleEvent = new RiskProfileLifecycleEvent(
EventId: "evt-003",
ProfileId: "test-profile",
Version: "1.0.0",
EventType: RiskProfileLifecycleEventType.Deprecated,
OldStatus: RiskProfileLifecycleStatus.Active,
NewStatus: RiskProfileLifecycleStatus.Deprecated,
Timestamp: _timeProvider.GetUtcNow(),
Actor: "alice@example.com",
Reason: "Superseded by v2.0.0");
// Act
await _service.NotifyFromLifecycleEventAsync(
"tenant-123",
lifecycleEvent,
profile: null,
"abc123hash",
CancellationToken.None);
// Assert
Assert.Single(_publisher.PublishedEvents);
var evt = _publisher.PublishedEvents[0];
Assert.Equal(PolicyProfileNotificationEventTypes.ProfileDeactivated, evt.EventType);
Assert.Equal("Superseded by v2.0.0", evt.ChangeReason);
}
[Fact]
public async Task NotifyProfileCreatedAsync_WhenDisabled_DoesNotPublish()
{
// Arrange
var disabledOptions = new PolicyProfileNotificationOptions { Enabled = false };
var disabledService = new PolicyProfileNotificationService(
_publisher,
_factory,
MsOptions.Options.Create(disabledOptions),
NullLogger<PolicyProfileNotificationService>.Instance);
var profile = CreateTestProfile();
// Act
await disabledService.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert
Assert.Empty(_publisher.PublishedEvents);
}
[Fact]
public async Task NotifyProfileCreatedAsync_WhenPublisherThrows_LogsWarningAndContinues()
{
// Arrange
var throwingPublisher = new ThrowingNotificationPublisher();
var serviceWithThrowingPublisher = new PolicyProfileNotificationService(
throwingPublisher,
_factory,
MsOptions.Options.Create(_options),
NullLogger<PolicyProfileNotificationService>.Instance);
var profile = CreateTestProfile();
// Act (should not throw)
await serviceWithThrowingPublisher.NotifyProfileCreatedAsync(
"tenant-123",
profile,
"alice@example.com",
"abc123hash",
CancellationToken.None);
// Assert - no exception thrown
Assert.True(true);
}
[Fact]
public void EventTypes_AreCorrect()
{
Assert.Equal("policy.profile.created", PolicyProfileNotificationEventTypes.ProfileCreated);
Assert.Equal("policy.profile.activated", PolicyProfileNotificationEventTypes.ProfileActivated);
Assert.Equal("policy.profile.deactivated", PolicyProfileNotificationEventTypes.ProfileDeactivated);
Assert.Equal("policy.profile.threshold_changed", PolicyProfileNotificationEventTypes.ThresholdChanged);
Assert.Equal("policy.profile.override_added", PolicyProfileNotificationEventTypes.OverrideAdded);
Assert.Equal("policy.profile.override_removed", PolicyProfileNotificationEventTypes.OverrideRemoved);
Assert.Equal("policy.profile.simulation_ready", PolicyProfileNotificationEventTypes.SimulationReady);
}
[Fact]
public void Factory_GeneratesUniqueEventIds()
{
// Arrange & Act
var event1 = _factory.CreateProfileCreatedEvent("t1", "p1", "1.0", null, null);
var event2 = _factory.CreateProfileCreatedEvent("t1", "p1", "1.0", null, null);
// Assert
Assert.NotEqual(event1.EventId, event2.EventId);
}
[Fact]
public void Factory_IncludesBaseUrlInLinks()
{
// Arrange & Act
var notification = _factory.CreateProfileActivatedEvent(
"tenant-123",
"my-profile",
"2.0.0",
"alice@example.com",
"hash123",
scope: null);
// Assert
Assert.NotNull(notification.Links);
Assert.Equal("https://policy.test.local/api/risk/profiles/my-profile", notification.Links.ProfileUrl);
}
[Fact]
public void Factory_DetectsUserActorType()
{
// Act
var userEvent = _factory.CreateProfileCreatedEvent("t", "p", "1.0", "alice@example.com", null);
var systemEvent = _factory.CreateProfileCreatedEvent("t", "p", "1.0", "policy-service", null);
// Assert
Assert.Equal("user", userEvent.Actor?.Type);
Assert.Equal("system", systemEvent.Actor?.Type);
}
private static RiskProfileModel CreateTestProfile()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile for unit tests",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "vuln", Type = RiskSignalType.Numeric, Path = "$.cvss.score" }
},
Weights = new Dictionary<string, double> { ["cvss"] = 1.0 },
Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>(),
Decisions = new List<DecisionOverride>()
}
};
}
private static RiskProfileModel CreateTestProfileWithThresholds()
{
return new RiskProfileModel
{
Id = "test-profile",
Version = "1.0.0",
Description = "Test profile with thresholds",
Signals = new List<RiskSignal>
{
new() { Name = "cvss", Source = "vuln", Type = RiskSignalType.Numeric, Path = "$.cvss.score" }
},
Weights = new Dictionary<string, double> { ["cvss"] = 1.0 },
Overrides = new RiskOverrides
{
Severity = new List<SeverityOverride>
{
new() { Set = RiskSeverity.Critical, When = new Dictionary<string, object> { ["score_gte"] = 0.9 } },
new() { Set = RiskSeverity.High, When = new Dictionary<string, object> { ["score_gte"] = 0.75 } },
new() { Set = RiskSeverity.Medium, When = new Dictionary<string, object> { ["score_gte"] = 0.5 } }
},
Decisions = new List<DecisionOverride>()
}
};
}
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class FakeNotificationPublisher : IPolicyProfileNotificationPublisher
{
public List<PolicyProfileNotificationEvent> PublishedEvents { get; } = new();
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
PublishedEvents.Add(notification);
return Task.CompletedTask;
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(true);
}
}
private sealed class ThrowingNotificationPublisher : IPolicyProfileNotificationPublisher
{
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("Publisher failed");
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("Publisher failed");
}
}
}

View File

@@ -0,0 +1,526 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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<ArgumentException>(() => 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;
public TenantContextMiddlewareTests()
{
_logger = NullLogger<TenantContextMiddleware>.Instance;
_tenantAccessor = new TenantContextAccessor();
_options = new TenantContextOptions
{
Enabled = true,
RequireTenantHeader = true,
ExcludedPaths = new List<string> { "/healthz", "/readyz" }
};
}
[Fact]
public async Task Middleware_WithValidTenantHeader_SetsTenantContext()
{
// Arrange
var nextCalled = false;
var middleware = new TenantContextMiddleware(
_ => { nextCalled = true; return Task.CompletedTask; },
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123");
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.True(nextCalled);
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
}
[Fact]
public async Task Middleware_WithTenantAndProjectHeaders_SetsBothInContext()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123", "project-456");
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
Assert.Equal("project-456", _tenantAccessor.TenantContext.ProjectId);
}
[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);
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
var optionsNotRequired = new TenantContextOptions
{
Enabled = true,
RequireTenantHeader = false
};
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(optionsNotRequired),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId: null);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(TenantContextConstants.DefaultTenantId, _tenantAccessor.TenantContext.TenantId);
}
[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);
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);
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
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", tenantId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(tenantId, _tenantAccessor.TenantContext.TenantId);
}
[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);
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);
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
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
var context = CreateHttpContext("/api/risk/profiles", "tenant-123", projectId);
// Act
await middleware.InvokeAsync(context, _tenantAccessor);
// Assert
Assert.NotNull(_tenantAccessor.TenantContext);
Assert.Equal(projectId, _tenantAccessor.TenantContext.ProjectId);
}
[Fact]
public async Task Middleware_WithWriteScope_SetsCanWriteTrue()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
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(_tenantAccessor.TenantContext);
Assert.True(_tenantAccessor.TenantContext.CanWrite);
}
[Fact]
public async Task Middleware_WithoutWriteScope_SetsCanWriteFalse()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
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(_tenantAccessor.TenantContext);
Assert.False(_tenantAccessor.TenantContext.CanWrite);
}
[Fact]
public async Task Middleware_ExtractsActorIdFromSubClaim()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
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(_tenantAccessor.TenantContext);
Assert.Equal("user-id-123", _tenantAccessor.TenantContext.ActorId);
}
[Fact]
public async Task Middleware_ExtractsActorIdFromHeader()
{
// Arrange
var middleware = new TenantContextMiddleware(
_ => Task.CompletedTask,
MsOptions.Options.Create(_options),
_logger);
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(_tenantAccessor.TenantContext);
Assert.Equal("service-account-123", _tenantAccessor.TenantContext.ActorId);
}
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);
}
}