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:
StellaOps Bot
2025-12-21 08:29:51 +02:00
parent b9c288782b
commit ba2f015184
37 changed files with 2825 additions and 1240 deletions

View File

@@ -0,0 +1,302 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Engine.Adapters;
/// <summary>
/// Options for exception adapter configuration.
/// </summary>
public sealed class ExceptionAdapterOptions
{
/// <summary>
/// Cache TTL for loaded exceptions. Default: 60 seconds.
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Maximum number of exceptions to load per tenant. Default: 10000.
/// </summary>
public int MaxExceptionsPerTenant { get; set; } = 10000;
/// <summary>
/// Whether to enable caching. Default: true.
/// </summary>
public bool EnableCaching { get; set; } = true;
}
/// <summary>
/// Interface for adapting persisted exception objects to policy evaluation context.
/// </summary>
internal interface IExceptionAdapter
{
/// <summary>
/// Loads active exceptions for a tenant and converts them to PolicyEvaluationExceptions.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="asOf">Point in time for expiry filtering (typically now).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy evaluation exceptions ready for use in evaluation context.</returns>
Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cached exceptions for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
void InvalidateCache(Guid tenantId);
/// <summary>
/// Invalidates all cached exceptions.
/// </summary>
void InvalidateAllCaches();
}
/// <summary>
/// Adapts persisted ExceptionObject entities to PolicyEvaluationExceptions for policy evaluation.
/// Includes caching layer for performance optimization.
/// </summary>
internal sealed class ExceptionAdapter : IExceptionAdapter
{
private readonly IExceptionRepository _repository;
private readonly IExceptionEffectRegistry _effectRegistry;
private readonly IMemoryCache _cache;
private readonly ExceptionAdapterOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionAdapter> _logger;
private static readonly string CacheKeyPrefix = "exception_adapter:";
public ExceptionAdapter(
IExceptionRepository repository,
IExceptionEffectRegistry effectRegistry,
IMemoryCache cache,
IOptions<ExceptionAdapterOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionAdapter> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_effectRegistry = effectRegistry ?? throw new ArgumentNullException(nameof(effectRegistry));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new ExceptionAdapterOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(tenantId);
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out PolicyEvaluationExceptions? cached) && cached is not null)
{
_logger.LogDebug("Cache hit for tenant {TenantId} exceptions", tenantId);
return cached;
}
_logger.LogDebug("Loading exceptions from repository for tenant {TenantId}", tenantId);
// Load active exceptions from repository
var exceptions = await LoadActiveExceptionsAsync(tenantId, asOf, cancellationToken);
// Convert to evaluation context format
var result = ConvertToEvaluationExceptions(exceptions, asOf);
// Cache the result
if (_options.EnableCaching)
{
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.CacheTtl,
Size = 1
};
_cache.Set(cacheKey, result, cacheOptions);
}
_logger.LogDebug(
"Loaded {Count} active exceptions for tenant {TenantId}",
result.Instances.Length,
tenantId);
return result;
}
/// <inheritdoc />
public void InvalidateCache(Guid tenantId)
{
var cacheKey = BuildCacheKey(tenantId);
_cache.Remove(cacheKey);
_logger.LogDebug("Invalidated exception cache for tenant {TenantId}", tenantId);
}
/// <inheritdoc />
public void InvalidateAllCaches()
{
// IMemoryCache doesn't support enumeration, so we can't clear all entries.
// In practice, callers should invalidate specific tenants or use a distributed cache
// with proper invalidation patterns.
_logger.LogWarning("InvalidateAllCaches called but IMemoryCache doesn't support enumeration. Consider using tenant-specific invalidation.");
}
private async Task<IReadOnlyList<ExceptionObject>> LoadActiveExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
// Create scope filter for active exceptions
var scope = new ExceptionScope
{
TenantId = tenantId
};
// Query repository for active exceptions not expired as of the given time
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
// Filter to only active status and not expired
return candidates
.Where(ex => ex.Status == ExceptionStatus.Active)
.Where(ex => ex.ExpiresAt > asOf)
.Take(_options.MaxExceptionsPerTenant)
.ToList();
}
private PolicyEvaluationExceptions ConvertToEvaluationExceptions(
IReadOnlyList<ExceptionObject> exceptions,
DateTimeOffset asOf)
{
if (exceptions.Count == 0)
{
return PolicyEvaluationExceptions.Empty;
}
var effectsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyExceptionEffect>(StringComparer.OrdinalIgnoreCase);
var instancesBuilder = ImmutableArray.CreateBuilder<PolicyEvaluationExceptionInstance>(exceptions.Count);
foreach (var exception in exceptions)
{
// Get or create effect for this exception type/reason
var effect = _effectRegistry.GetEffect(exception.Type, exception.ReasonCode);
var effectId = effect.Id;
// Add effect to dictionary (de-duplicate by ID)
if (!effectsBuilder.ContainsKey(effectId))
{
effectsBuilder.Add(effectId, effect);
}
// Create scope from exception scope
var scope = ConvertScope(exception.Scope);
// Create instance
var instance = new PolicyEvaluationExceptionInstance(
Id: exception.ExceptionId,
EffectId: effectId,
Scope: scope,
CreatedAt: exception.CreatedAt,
Metadata: BuildMetadata(exception));
instancesBuilder.Add(instance);
}
return new PolicyEvaluationExceptions(
Effects: effectsBuilder.ToImmutable(),
Instances: instancesBuilder.ToImmutable());
}
private static PolicyEvaluationExceptionScope ConvertScope(ExceptionScope scope)
{
// Map exception scope to evaluation scope
// Policy rule IDs go to RuleNames
// Vulnerability IDs go to Sources (advisory source matching)
// PURL patterns go to Tags (for component matching)
var ruleNames = !string.IsNullOrEmpty(scope.PolicyRuleId)
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.PolicyRuleId)
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
var sources = !string.IsNullOrEmpty(scope.VulnerabilityId)
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.VulnerabilityId)
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
var tags = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(scope.PurlPattern))
{
// Use PURL pattern as a tag for component-based matching
tags = tags.Add($"purl:{scope.PurlPattern}");
}
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
{
tags = tags.Add($"digest:{scope.ArtifactDigest}");
}
// Environments are stored as tags with env: prefix
foreach (var env in scope.Environments)
{
tags = tags.Add($"env:{env}");
}
// Severities are not directly mapped from ExceptionScope
// They would come from effect configuration
var severities = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
return new PolicyEvaluationExceptionScope(
RuleNames: ruleNames,
Severities: severities,
Sources: sources,
Tags: tags);
}
private static ImmutableDictionary<string, string> BuildMetadata(ExceptionObject exception)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
builder["exception.type"] = exception.Type.ToString();
builder["exception.reason"] = exception.ReasonCode.ToString();
builder["exception.owner"] = exception.OwnerId;
builder["exception.requester"] = exception.RequesterId;
builder["exception.rationale"] = exception.Rationale;
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
if (exception.ApproverIds.Length > 0)
{
builder["exception.approvers"] = string.Join(",", exception.ApproverIds);
}
if (!string.IsNullOrEmpty(exception.TicketRef))
{
builder["exception.ticketRef"] = exception.TicketRef;
}
if (exception.EvidenceRefs.Length > 0)
{
builder["exception.evidenceRefs"] = string.Join(",", exception.EvidenceRefs);
}
if (exception.CompensatingControls.Length > 0)
{
builder["exception.compensatingControls"] = string.Join(",", exception.CompensatingControls);
}
// Copy custom metadata
foreach (var pair in exception.Metadata)
{
if (!builder.ContainsKey(pair.Key))
{
builder[$"meta.{pair.Key}"] = pair.Value;
}
}
return builder.ToImmutable();
}
private static string BuildCacheKey(Guid tenantId) => $"{CacheKeyPrefix}{tenantId:N}";
}

View File

@@ -0,0 +1,226 @@
using System.Collections.Frozen;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Engine.Adapters;
/// <summary>
/// Interface for looking up exception effects based on type and reason.
/// </summary>
public interface IExceptionEffectRegistry
{
/// <summary>
/// Gets the policy exception effect for a given exception type and reason.
/// </summary>
/// <param name="type">Exception type.</param>
/// <param name="reason">Exception reason code.</param>
/// <returns>The corresponding policy exception effect.</returns>
PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason);
/// <summary>
/// Gets all registered effects.
/// </summary>
IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects();
/// <summary>
/// Gets effect by ID.
/// </summary>
/// <param name="effectId">Effect identifier.</param>
/// <returns>Effect if found, null otherwise.</returns>
PolicyExceptionEffect? GetEffectById(string effectId);
}
/// <summary>
/// Registry mapping exception type/reason combinations to policy exception effects.
/// </summary>
/// <remarks>
/// Effect mappings follow the auditable exception principles:
/// - Suppress: Finding is suppressed from reports and gates
/// - Defer: Finding is deferred (tracked but not blocking)
/// - Downgrade: Finding severity is reduced
/// - RequireControl: Finding requires compensating control verification
/// </remarks>
public sealed class ExceptionEffectRegistry : IExceptionEffectRegistry
{
private readonly FrozenDictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> _effectMap;
private readonly FrozenDictionary<string, PolicyExceptionEffect> _effectsById;
private readonly PolicyExceptionEffect _defaultEffect;
public ExceptionEffectRegistry()
{
var effects = BuildDefaultEffects();
_effectMap = effects.ToFrozenDictionary();
_effectsById = effects.Values
.DistinctBy(e => e.Id)
.ToFrozenDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
_defaultEffect = CreateDefaultEffect();
}
/// <inheritdoc />
public PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason)
{
return _effectMap.TryGetValue((type, reason), out var effect)
? effect
: _defaultEffect;
}
/// <inheritdoc />
public IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects()
{
return _effectsById.Values;
}
/// <inheritdoc />
public PolicyExceptionEffect? GetEffectById(string effectId)
{
return _effectsById.TryGetValue(effectId, out var effect) ? effect : null;
}
private static Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> BuildDefaultEffects()
{
// Define all effect templates
var suppress = new PolicyExceptionEffect(
Id: "suppress",
Name: "Suppress Finding",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Suppresses the finding from reports and policy gates.");
var defer = new PolicyExceptionEffect(
Id: "defer",
Name: "Defer Finding",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "deferred-review",
MaxDurationDays: 90,
Description: "Defers the finding for later review without blocking.");
var requireControl = new PolicyExceptionEffect(
Id: "require-control",
Name: "Require Compensating Control",
Effect: PolicyExceptionEffectType.RequireControl,
DowngradeSeverity: null,
RequiredControlId: "compensating-control-verification",
RoutingTemplate: "control-verification",
MaxDurationDays: 180,
Description: "Requires verification of compensating controls before allowing.");
var downgradeToLow = new PolicyExceptionEffect(
Id: "downgrade-low",
Name: "Downgrade to Low",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Low,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Downgrades finding severity to Low.");
var downgradeToMedium = new PolicyExceptionEffect(
Id: "downgrade-medium",
Name: "Downgrade to Medium",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Medium,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Downgrades finding severity to Medium.");
var deferVendor = new PolicyExceptionEffect(
Id: "defer-vendor",
Name: "Awaiting Vendor Fix",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "vendor-tracking",
MaxDurationDays: 180,
Description: "Defers pending vendor patch release.");
var suppressDeprecation = new PolicyExceptionEffect(
Id: "suppress-deprecation",
Name: "Deprecation Waiver",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "deprecation-tracking",
MaxDurationDays: 90,
Description: "Temporary waiver during component deprecation.");
var suppressLicense = new PolicyExceptionEffect(
Id: "suppress-license",
Name: "License Waiver",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "legal-review",
MaxDurationDays: 365,
Description: "License compliance waiver after legal review.");
// Build the mapping
return new Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect>
{
// Vulnerability exceptions
[(ExceptionType.Vulnerability, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Vulnerability, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Vulnerability, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
[(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Vulnerability, ExceptionReason.Other)] = defer,
// Policy exceptions
[(ExceptionType.Policy, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Policy, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Policy, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Policy, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Policy, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Policy, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Policy, ExceptionReason.DeprecationInProgress)] = defer,
[(ExceptionType.Policy, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Policy, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Policy, ExceptionReason.Other)] = defer,
// Unknown findings exceptions
[(ExceptionType.Unknown, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Unknown, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Unknown, ExceptionReason.DeprecationInProgress)] = defer,
[(ExceptionType.Unknown, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Unknown, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Unknown, ExceptionReason.Other)] = defer,
// Component exceptions
[(ExceptionType.Component, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Component, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Component, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Component, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Component, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Component, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Component, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
[(ExceptionType.Component, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Component, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Component, ExceptionReason.Other)] = suppressLicense,
};
}
private static PolicyExceptionEffect CreateDefaultEffect()
{
return new PolicyExceptionEffect(
Id: "defer-default",
Name: "Default Deferral",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "manual-review",
MaxDurationDays: 30,
Description: "Default effect for unmapped exception type/reason combinations.");
}
}

View File

@@ -290,4 +290,32 @@ public static class PolicyEngineServiceCollectionExtensions
services.Configure(configure);
return services.AddPolicyEngine();
}
}
/// <summary>
/// Adds exception integration services for automatic exception loading during policy evaluation.
/// Requires IExceptionRepository to be registered.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Optional configuration for exception adapter options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyExceptionIntegration(
this IServiceCollection services,
Action<Adapters.ExceptionAdapterOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
// Register the effect registry (singleton, stateless)
services.TryAddSingleton<Adapters.IExceptionEffectRegistry, Adapters.ExceptionEffectRegistry>();
// Register the exception adapter (singleton, uses IMemoryCache for caching)
services.TryAddSingleton<Adapters.IExceptionAdapter, Adapters.ExceptionAdapter>();
// Register the exception-aware evaluation service
services.TryAddSingleton<Services.IExceptionAwareEvaluationService, Services.ExceptionAwareEvaluationService>();
return services;
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Engine.Domain;

View File

@@ -0,0 +1,320 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Adapters;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Request for exception-aware policy evaluation.
/// Extends the base RuntimeEvaluationRequest with exception loading options.
/// </summary>
internal sealed record ExceptionAwareEvaluationRequest
{
/// <summary>
/// Base evaluation request.
/// </summary>
public required RuntimeEvaluationRequest BaseRequest { get; init; }
/// <summary>
/// Whether to automatically load exceptions from the repository.
/// If false, uses exceptions from BaseRequest.Exceptions (default behavior).
/// If true, loads exceptions for the tenant and merges with any provided exceptions.
/// </summary>
public bool LoadExceptionsFromRepository { get; init; } = true;
/// <summary>
/// Tenant ID for loading exceptions. Required if LoadExceptionsFromRepository is true.
/// Falls back to parsing from TenantId in BaseRequest if not provided.
/// </summary>
public Guid? ExceptionTenantId { get; init; }
}
/// <summary>
/// Response from exception-aware policy evaluation.
/// </summary>
internal sealed record ExceptionAwareEvaluationResponse
{
/// <summary>
/// The underlying evaluation response.
/// </summary>
public required RuntimeEvaluationResponse Response { get; init; }
/// <summary>
/// Number of exceptions that were loaded from the repository.
/// </summary>
public int LoadedExceptionCount { get; init; }
/// <summary>
/// Whether exceptions were loaded from the repository.
/// </summary>
public bool ExceptionsLoadedFromRepository { get; init; }
/// <summary>
/// Duration of exception loading in milliseconds.
/// </summary>
public long ExceptionLoadDurationMs { get; init; }
}
/// <summary>
/// Interface for exception-aware policy evaluation.
/// Automatically loads exceptions from the repository before evaluation.
/// </summary>
internal interface IExceptionAwareEvaluationService
{
/// <summary>
/// Evaluates a policy with automatic exception loading.
/// </summary>
Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
ExceptionAwareEvaluationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates multiple requests in batch with automatic exception loading.
/// Exceptions are loaded once per tenant for efficiency.
/// </summary>
Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Exception-aware policy evaluation service.
/// Wraps PolicyRuntimeEvaluationService and automatically loads exceptions from the repository.
/// </summary>
internal sealed class ExceptionAwareEvaluationService : IExceptionAwareEvaluationService
{
private readonly PolicyRuntimeEvaluationService _evaluator;
private readonly IExceptionAdapter _exceptionAdapter;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionAwareEvaluationService> _logger;
public ExceptionAwareEvaluationService(
PolicyRuntimeEvaluationService evaluator,
IExceptionAdapter exceptionAdapter,
TimeProvider timeProvider,
ILogger<ExceptionAwareEvaluationService> logger)
{
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
_exceptionAdapter = exceptionAdapter ?? throw new ArgumentNullException(nameof(exceptionAdapter));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
ExceptionAwareEvaluationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.BaseRequest);
var loadStartTimestamp = _timeProvider.GetTimestamp();
var loadedCount = 0;
var exceptionsLoaded = false;
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
if (request.LoadExceptionsFromRepository)
{
var tenantId = ResolveTenantId(request);
if (tenantId.HasValue)
{
var asOf = request.BaseRequest.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
var loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
tenantId.Value,
asOf,
cancellationToken);
// Merge loaded exceptions with any exceptions in the original request
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
loadedCount = loadedExceptions.Instances.Length;
exceptionsLoaded = true;
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
_logger.LogDebug(
"Loaded {Count} exceptions for tenant {TenantId} in evaluation request",
loadedCount,
tenantId.Value);
}
else
{
_logger.LogWarning(
"LoadExceptionsFromRepository is true but no tenant ID available. " +
"Falling back to exceptions in request.");
}
}
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
// Delegate to core evaluator
var response = await _evaluator.EvaluateAsync(enrichedRequest, cancellationToken);
// Record telemetry for exception loading
if (exceptionsLoaded && loadedCount > 0)
{
PolicyEngineTelemetry.RecordExceptionLoaded(
enrichedRequest.TenantId,
loadedCount,
loadDuration / 1000.0);
}
return new ExceptionAwareEvaluationResponse
{
Response = response,
LoadedExceptionCount = loadedCount,
ExceptionsLoadedFromRepository = exceptionsLoaded,
ExceptionLoadDurationMs = loadDuration
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
CancellationToken cancellationToken = default)
{
if (requests.Count == 0)
{
return Array.Empty<ExceptionAwareEvaluationResponse>();
}
var loadStartTimestamp = _timeProvider.GetTimestamp();
// Group requests by tenant to load exceptions efficiently
var tenantExceptionsCache = new Dictionary<Guid, PolicyEvaluationExceptions>();
var enrichedRequests = new List<(ExceptionAwareEvaluationRequest Original, RuntimeEvaluationRequest Enriched, int LoadedCount)>();
var asOf = _timeProvider.GetUtcNow();
foreach (var request in requests)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.BaseRequest);
var loadedCount = 0;
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
if (request.LoadExceptionsFromRepository)
{
var tenantId = ResolveTenantId(request);
if (tenantId.HasValue)
{
// Check cache first
if (!tenantExceptionsCache.TryGetValue(tenantId.Value, out var loadedExceptions))
{
var requestAsOf = request.BaseRequest.EvaluationTimestamp ?? asOf;
loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
tenantId.Value,
requestAsOf,
cancellationToken);
tenantExceptionsCache[tenantId.Value] = loadedExceptions;
}
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
loadedCount = loadedExceptions.Instances.Length;
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
}
}
enrichedRequests.Add((request, enrichedRequest, loadedCount));
}
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
// Evaluate all enriched requests
var baseRequests = enrichedRequests.Select(e => e.Enriched).ToList();
var responses = await _evaluator.EvaluateBatchAsync(baseRequests, cancellationToken);
// Build responses
var results = new List<ExceptionAwareEvaluationResponse>(requests.Count);
for (int i = 0; i < enrichedRequests.Count; i++)
{
var (original, _, loadedCount) = enrichedRequests[i];
results.Add(new ExceptionAwareEvaluationResponse
{
Response = responses[i],
LoadedExceptionCount = loadedCount,
ExceptionsLoadedFromRepository = original.LoadExceptionsFromRepository,
ExceptionLoadDurationMs = loadDuration / requests.Count // Amortized
});
}
_logger.LogDebug(
"Batch evaluation with exception loading: {RequestCount} requests, {TenantCount} tenants, {TotalLoaded} total exceptions loaded",
requests.Count,
tenantExceptionsCache.Count,
tenantExceptionsCache.Values.Sum(e => e.Instances.Length));
return results;
}
private Guid? ResolveTenantId(ExceptionAwareEvaluationRequest request)
{
// First try explicit exception tenant ID
if (request.ExceptionTenantId.HasValue)
{
return request.ExceptionTenantId.Value;
}
// Then try parsing from TenantId string in base request
if (Guid.TryParse(request.BaseRequest.TenantId, out var parsedTenantId))
{
return parsedTenantId;
}
return null;
}
private static PolicyEvaluationExceptions MergeExceptions(
PolicyEvaluationExceptions original,
PolicyEvaluationExceptions loaded)
{
if (original.IsEmpty)
{
return loaded;
}
if (loaded.IsEmpty)
{
return original;
}
// Merge effects (loaded takes precedence for same ID)
var mergedEffects = original.Effects.ToBuilder();
foreach (var effect in loaded.Effects)
{
mergedEffects[effect.Key] = effect.Value;
}
// Merge instances (combine and de-duplicate by ID)
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedInstances = new List<PolicyEvaluationExceptionInstance>();
// Add original instances first (these take precedence as they were explicitly provided)
foreach (var instance in original.Instances)
{
if (seenIds.Add(instance.Id))
{
mergedInstances.Add(instance);
}
}
// Add loaded instances that don't conflict
foreach (var instance in loaded.Instances)
{
if (seenIds.Add(instance.Id))
{
mergedInstances.Add(instance);
}
}
return new PolicyEvaluationExceptions(
mergedEffects.ToImmutable(),
mergedInstances.ToImmutableArray());
}
private long GetElapsedMilliseconds(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
return (long)elapsed.TotalMilliseconds;
}
}

View File

@@ -469,6 +469,20 @@ public static class PolicyEngineTelemetry
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
// Counter: policy_exception_loaded_total{tenant}
private static readonly Counter<long> ExceptionLoadedCounter =
Meter.CreateCounter<long>(
"policy_exception_loaded_total",
unit: "exceptions",
description: "Total exceptions loaded from repository for evaluation.");
// Histogram: policy_exception_load_latency_seconds{tenant}
private static readonly Histogram<double> ExceptionLoadLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_load_latency_seconds",
unit: "s",
description: "Latency of loading exceptions from repository.");
/// <summary>
/// Counter for exception cache operations.
/// </summary>
@@ -879,6 +893,23 @@ public static class PolicyEngineTelemetry
ExceptionLifecycleCounter.Add(1, tags);
}
/// <summary>
/// Records exceptions loaded from repository for evaluation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="count">Number of exceptions loaded.</param>
/// <param name="latencySeconds">Time taken to load exceptions in seconds.</param>
public static void RecordExceptionLoaded(string tenant, int count, double latencySeconds)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
};
ExceptionLoadedCounter.Add(count, tags);
ExceptionLoadLatencyHistogram.Record(latencySeconds, tags);
}
#region Golden Signals - Recording Methods
/// <summary>

View File

@@ -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() ?? []
};
}
}

View File

@@ -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();
}
}
}