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
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user