Implement Exception Effect Registry and Evaluation Service
- Added IExceptionEffectRegistry interface and its implementation ExceptionEffectRegistry to manage exception effects based on type and reason. - Created ExceptionAwareEvaluationService for evaluating policies with automatic exception loading from the repository. - Developed unit tests for ExceptionAdapter and ExceptionEffectRegistry to ensure correct behavior and mappings of exceptions and effects. - Enhanced exception loading logic to filter expired and non-active exceptions, and to respect maximum exceptions limit. - Implemented caching mechanism in ExceptionAdapter to optimize repeated exception loading.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionAdapter.
|
||||
/// </summary>
|
||||
public sealed class ExceptionAdapterTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IExceptionRepository> _repositoryMock;
|
||||
private readonly IExceptionEffectRegistry _effectRegistry;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ExceptionAdapterOptions _options;
|
||||
private readonly ExceptionAdapter _adapter;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public ExceptionAdapterTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IExceptionRepository>();
|
||||
_effectRegistry = new ExceptionEffectRegistry();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_options = new ExceptionAdapterOptions
|
||||
{
|
||||
CacheTtl = TimeSpan.FromSeconds(60),
|
||||
EnableCaching = true,
|
||||
MaxExceptionsPerTenant = 10000
|
||||
};
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(_options),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_ReturnsEmpty_WhenNoExceptionsExist()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ExceptionObject>());
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsEmpty.Should().BeTrue();
|
||||
result.Instances.Should().BeEmpty();
|
||||
result.Effects.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var expiredException = CreateException("EXC-002", ExceptionStatus.Active, now.AddDays(-1)); // Expired
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, expiredException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersNonActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var proposedException = CreateException("EXC-002", ExceptionStatus.Proposed, now.AddDays(30));
|
||||
var revokedException = CreateException("EXC-003", ExceptionStatus.Revoked, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, proposedException, revokedException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsExceptionTypeAndReasonToEffect()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ExceptionType.Vulnerability,
|
||||
ExceptionReason.FalsePositive);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Effects.Should().ContainKey("suppress"); // FalsePositive maps to Suppress
|
||||
result.Instances[0].EffectId.Should().Be("suppress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsScopeCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
scope: new ExceptionScope
|
||||
{
|
||||
PolicyRuleId = "block_critical",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PurlPattern = "pkg:npm/lodash@*",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
Environments = ["production", "staging"]
|
||||
});
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
var instance = result.Instances[0];
|
||||
|
||||
// Policy rule ID maps to RuleNames
|
||||
instance.Scope.RuleNames.Should().Contain("block_critical");
|
||||
|
||||
// Vulnerability ID maps to Sources
|
||||
instance.Scope.Sources.Should().Contain("CVE-2024-1234");
|
||||
|
||||
// PURL pattern maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("purl:pkg:npm/lodash@*");
|
||||
|
||||
// Artifact digest maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("digest:sha256:abc123");
|
||||
|
||||
// Environments map to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("env:production");
|
||||
instance.Scope.Tags.Should().Contain("env:staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BuildsMetadataCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ticketRef: "JIRA-1234",
|
||||
evidenceRefs: new[] { "sha256:evidence1", "sha256:evidence2" },
|
||||
compensatingControls: new[] { "WAF", "Rate-limiting" });
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
var instance = result.Instances[0];
|
||||
instance.Metadata.Should().ContainKey("exception.type");
|
||||
instance.Metadata.Should().ContainKey("exception.reason");
|
||||
instance.Metadata.Should().ContainKey("exception.owner");
|
||||
instance.Metadata.Should().ContainKey("exception.requester");
|
||||
instance.Metadata.Should().ContainKey("exception.rationale");
|
||||
instance.Metadata.Should().ContainKey("exception.ticketRef");
|
||||
instance.Metadata["exception.ticketRef"].Should().Be("JIRA-1234");
|
||||
instance.Metadata.Should().ContainKey("exception.evidenceRefs");
|
||||
instance.Metadata.Should().ContainKey("exception.compensatingControls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_UsesCacheOnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act - First call
|
||||
var result1 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Act - Second call (should hit cache)
|
||||
var result2 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2);
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once); // Should only call repository once
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BypassesCache_WhenCachingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var disabledCacheOptions = new ExceptionAdapterOptions { EnableCaching = false };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(disabledCacheOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2)); // Should call repository twice
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateCache_RemovesCacheEntry()
|
||||
{
|
||||
// Arrange - pre-populate cache
|
||||
var cacheKey = $"exception_adapter:{_tenantId:N}";
|
||||
_cache.Set(cacheKey, PolicyEvaluationExceptions.Empty);
|
||||
|
||||
// Act
|
||||
_adapter.InvalidateCache(_tenantId);
|
||||
|
||||
// Assert
|
||||
_cache.TryGetValue(cacheKey, out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_RespectsMaxExceptionsLimit()
|
||||
{
|
||||
// Arrange
|
||||
var limitedOptions = new ExceptionAdapterOptions { MaxExceptionsPerTenant = 2 };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(limitedOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exceptions = Enumerable.Range(1, 10)
|
||||
.Select(i => CreateException($"EXC-{i:000}", ExceptionStatus.Active, now.AddDays(30)))
|
||||
.ToArray();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exceptions);
|
||||
|
||||
// Act
|
||||
var result = await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string exceptionId,
|
||||
ExceptionStatus status,
|
||||
DateTimeOffset expiresAt,
|
||||
ExceptionType type = ExceptionType.Vulnerability,
|
||||
ExceptionReason reason = ExceptionReason.AcceptedRisk,
|
||||
ExceptionScope? scope = null,
|
||||
string? ticketRef = null,
|
||||
string[]? evidenceRefs = null,
|
||||
string[]? compensatingControls = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = type,
|
||||
Scope = scope ?? new ExceptionScope
|
||||
{
|
||||
TenantId = Guid.NewGuid()
|
||||
},
|
||||
OwnerId = "owner-001",
|
||||
RequesterId = "requester-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt,
|
||||
ReasonCode = reason,
|
||||
Rationale = "This is a test rationale that meets the minimum character requirement for exception objects.",
|
||||
TicketRef = ticketRef,
|
||||
EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [],
|
||||
CompensatingControls = compensatingControls?.ToImmutableArray() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionEffectRegistry.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEffectRegistryTests
|
||||
{
|
||||
private readonly IExceptionEffectRegistry _registry;
|
||||
|
||||
public ExceptionEffectRegistryTests()
|
||||
{
|
||||
_registry = new ExceptionEffectRegistry();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.TestOnly, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation, PolicyExceptionEffectType.Downgrade)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation, PolicyExceptionEffectType.Downgrade)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForVulnerabilityType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForPolicyType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForUnknownType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.DeprecationInProgress, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.Other, PolicyExceptionEffectType.Suppress)] // License waiver
|
||||
public void GetEffect_ReturnsCorrectEffect_ForComponentType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffect_ReturnsDefaultDeferral_ForUnmappedCombination()
|
||||
{
|
||||
// Note: All combinations are mapped, so we test a hypothetical case
|
||||
// by checking that the registry handles all known combinations
|
||||
var allTypes = Enum.GetValues<ExceptionType>();
|
||||
var allReasons = Enum.GetValues<ExceptionReason>();
|
||||
|
||||
foreach (var type in allTypes)
|
||||
{
|
||||
foreach (var reason in allReasons)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert - should never be null
|
||||
effect.Should().NotBeNull();
|
||||
effect.Id.Should().NotBeNullOrEmpty();
|
||||
effect.Effect.Should().BeOneOf(
|
||||
PolicyExceptionEffectType.Suppress,
|
||||
PolicyExceptionEffectType.Defer,
|
||||
PolicyExceptionEffectType.Downgrade,
|
||||
PolicyExceptionEffectType.RequireControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllEffects_ReturnsDistinctEffects()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
allEffects.Should().NotBeEmpty();
|
||||
allEffects.Should().OnlyHaveUniqueItems(e => e.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsEffect_WhenExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("suppress");
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect!.Id.Should().Be("suppress");
|
||||
effect.Effect.Should().Be(PolicyExceptionEffectType.Suppress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("non-existent-effect-id");
|
||||
|
||||
// Assert
|
||||
effect.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var effect1 = _registry.GetEffectById("SUPPRESS");
|
||||
var effect2 = _registry.GetEffectById("suppress");
|
||||
var effect3 = _registry.GetEffectById("Suppress");
|
||||
|
||||
// Assert
|
||||
effect1.Should().Be(effect2);
|
||||
effect2.Should().Be(effect3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effects_HaveValidProperties()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
foreach (var effect in allEffects)
|
||||
{
|
||||
effect.Id.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Name.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Description.Should().NotBeNullOrWhiteSpace();
|
||||
effect.MaxDurationDays.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeEffects_HaveValidSeverity()
|
||||
{
|
||||
// Act
|
||||
var downgradeEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Downgrade);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in downgradeEffects)
|
||||
{
|
||||
effect.DowngradeSeverity.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireControlEffects_HaveControlId()
|
||||
{
|
||||
// Act
|
||||
var requireControlEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.RequireControl);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in requireControlEffects)
|
||||
{
|
||||
effect.RequiredControlId.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuppressEffects_DoNotRequireControl()
|
||||
{
|
||||
// Act
|
||||
var suppressEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Suppress);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in suppressEffects)
|
||||
{
|
||||
// Suppress effects should not require controls
|
||||
effect.RequiredControlId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user