Add unit tests for ExceptionEvaluator, ExceptionEvent, ExceptionHistory, and ExceptionObject models

- Implemented comprehensive unit tests for the ExceptionEvaluator service, covering various scenarios including matching exceptions, environment checks, and evidence references.
- Created tests for the ExceptionEvent model to validate event creation methods and ensure correct event properties.
- Developed tests for the ExceptionHistory model to verify event count, order, and timestamps.
- Added tests for the ExceptionObject domain model to ensure validity checks and property preservation for various fields.
This commit is contained in:
StellaOps Bot
2025-12-21 00:34:35 +02:00
parent 6928124d33
commit b7b27c8740
32 changed files with 8687 additions and 64 deletions

View File

@@ -0,0 +1,287 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Policy.Engine.Domain;
// ============================================================================
// Exception API DTOs - Request Models
// ============================================================================
/// <summary>
/// Request to create a new exception.
/// </summary>
public sealed record CreateExceptionRequest
{
/// <summary>
/// Type of exception: vulnerability, policy, unknown, or component.
/// </summary>
[Required]
public required string Type { get; init; }
/// <summary>
/// Scope constraints for the exception.
/// </summary>
[Required]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Categorized reason for the exception.
/// </summary>
[Required]
public required string ReasonCode { get; init; }
/// <summary>
/// Detailed rationale explaining why this exception is necessary.
/// </summary>
[Required]
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
public required string Rationale { get; init; }
/// <summary>
/// When the exception should expire. Required and must be in the future.
/// </summary>
[Required]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Content-addressed references to supporting evidence.
/// </summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls in place that mitigate the risk.
/// </summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Additional metadata for organization-specific tracking.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Ticket or tracking system reference (e.g., JIRA-1234).
/// </summary>
[StringLength(100)]
public string? TicketRef { get; init; }
}
/// <summary>
/// Exception scope constraints.
/// </summary>
public sealed record ExceptionScopeDto
{
/// <summary>
/// Specific artifact digest (sha256:...) this exception applies to.
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// PURL pattern this exception applies to (supports wildcards).
/// </summary>
public string? PurlPattern { get; init; }
/// <summary>
/// Specific vulnerability ID (CVE-XXXX-XXXXX) this exception applies to.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Policy rule identifier this exception bypasses.
/// </summary>
public string? PolicyRuleId { get; init; }
/// <summary>
/// Environments where this exception is valid. Empty means all environments.
/// </summary>
public IReadOnlyList<string>? Environments { get; init; }
}
/// <summary>
/// Request to update an existing exception.
/// </summary>
public sealed record UpdateExceptionRequest
{
/// <summary>
/// Version of the exception for optimistic concurrency.
/// </summary>
[Required]
public required int Version { get; init; }
/// <summary>
/// Updated rationale (if changing).
/// </summary>
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
public string? Rationale { get; init; }
/// <summary>
/// Updated evidence references.
/// </summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Updated compensating controls.
/// </summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Updated metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Updated ticket reference.
/// </summary>
[StringLength(100)]
public string? TicketRef { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveExceptionRequest
{
/// <summary>
/// Optional comment from the approver.
/// </summary>
[StringLength(500)]
public string? Comment { get; init; }
}
/// <summary>
/// Request to extend an exception's expiry.
/// </summary>
public sealed record ExtendExceptionRequest
{
/// <summary>
/// Version of the exception for optimistic concurrency.
/// </summary>
[Required]
public required int Version { get; init; }
/// <summary>
/// New expiry date (must be in the future, max 1 year from now).
/// </summary>
[Required]
public required DateTimeOffset NewExpiresAt { get; init; }
/// <summary>
/// Reason for the extension.
/// </summary>
[StringLength(500)]
public string? Reason { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
public sealed record RevokeExceptionRequest
{
/// <summary>
/// Reason for revoking the exception.
/// </summary>
[Required]
[StringLength(500)]
public required string Reason { get; init; }
}
// ============================================================================
// Exception API DTOs - Response Models
// ============================================================================
/// <summary>
/// Full exception response.
/// </summary>
public sealed record ExceptionDto
{
public required string ExceptionId { get; init; }
public required int Version { get; init; }
public required string Status { get; init; }
public required string Type { get; init; }
public required ExceptionScopeDto Scope { get; init; }
public required string OwnerId { get; init; }
public required string RequesterId { get; init; }
public IReadOnlyList<string> ApproverIds { get; init; } = [];
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required string ReasonCode { get; init; }
public required string Rationale { get; init; }
public IReadOnlyList<string> EvidenceRefs { get; init; } = [];
public IReadOnlyList<string> CompensatingControls { get; init; } = [];
public IReadOnlyDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
public string? TicketRef { get; init; }
public bool IsEffective { get; init; }
public bool HasExpired { get; init; }
}
/// <summary>
/// Summary exception for list responses.
/// </summary>
public sealed record ExceptionSummaryDto
{
public required string ExceptionId { get; init; }
public required string Status { get; init; }
public required string Type { get; init; }
public string? VulnerabilityId { get; init; }
public string? PurlPattern { get; init; }
public required string OwnerId { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required string ReasonCode { get; init; }
public bool IsEffective { get; init; }
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record ExceptionListResponse
{
public required IReadOnlyList<ExceptionSummaryDto> Items { get; init; }
public required int TotalCount { get; init; }
public required int Limit { get; init; }
public required int Offset { get; init; }
public bool HasMore => Offset + Items.Count < TotalCount;
}
/// <summary>
/// Exception event for audit history.
/// </summary>
public sealed record ExceptionEventDto
{
public required Guid EventId { get; init; }
public required int SequenceNumber { get; init; }
public required string EventType { get; init; }
public required string ActorId { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? PreviousStatus { get; init; }
public required string NewStatus { get; init; }
public int NewVersion { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Details { get; init; }
}
/// <summary>
/// Exception history response.
/// </summary>
public sealed record ExceptionHistoryResponse
{
public required string ExceptionId { get; init; }
public required IReadOnlyList<ExceptionEventDto> Events { get; init; }
public required int EventCount { get; init; }
public DateTimeOffset? FirstEventAt { get; init; }
public DateTimeOffset? LastEventAt { get; init; }
}
/// <summary>
/// Exception counts by status.
/// </summary>
public sealed record ExceptionCountsDto
{
public int Total { get; init; }
public int Proposed { get; init; }
public int Approved { get; init; }
public int Active { get; init; }
public int Expired { get; init; }
public int Revoked { get; init; }
public int ExpiringSoon { get; init; }
}

View File

@@ -0,0 +1,260 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Engine.Domain;
/// <summary>
/// Maps between Exception domain models and API DTOs.
/// </summary>
public static class ExceptionMapper
{
/// <summary>
/// Maps an ExceptionObject to a full DTO.
/// </summary>
public static ExceptionDto ToDto(ExceptionObject exception)
{
return new ExceptionDto
{
ExceptionId = exception.ExceptionId,
Version = exception.Version,
Status = StatusToString(exception.Status),
Type = TypeToString(exception.Type),
Scope = ToScopeDto(exception.Scope),
OwnerId = exception.OwnerId,
RequesterId = exception.RequesterId,
ApproverIds = exception.ApproverIds.ToList(),
CreatedAt = exception.CreatedAt,
UpdatedAt = exception.UpdatedAt,
ApprovedAt = exception.ApprovedAt,
ExpiresAt = exception.ExpiresAt,
ReasonCode = ReasonToString(exception.ReasonCode),
Rationale = exception.Rationale,
EvidenceRefs = exception.EvidenceRefs.ToList(),
CompensatingControls = exception.CompensatingControls.ToList(),
Metadata = exception.Metadata,
TicketRef = exception.TicketRef,
IsEffective = exception.IsEffective,
HasExpired = exception.HasExpired
};
}
/// <summary>
/// Maps an ExceptionObject to a summary DTO for list responses.
/// </summary>
public static ExceptionSummaryDto ToSummaryDto(ExceptionObject exception)
{
return new ExceptionSummaryDto
{
ExceptionId = exception.ExceptionId,
Status = StatusToString(exception.Status),
Type = TypeToString(exception.Type),
VulnerabilityId = exception.Scope.VulnerabilityId,
PurlPattern = exception.Scope.PurlPattern,
OwnerId = exception.OwnerId,
ExpiresAt = exception.ExpiresAt,
ReasonCode = ReasonToString(exception.ReasonCode),
IsEffective = exception.IsEffective
};
}
/// <summary>
/// Maps an ExceptionScope to a DTO.
/// </summary>
public static ExceptionScopeDto ToScopeDto(ExceptionScope scope)
{
return new ExceptionScopeDto
{
ArtifactDigest = scope.ArtifactDigest,
PurlPattern = scope.PurlPattern,
VulnerabilityId = scope.VulnerabilityId,
PolicyRuleId = scope.PolicyRuleId,
Environments = scope.Environments.IsEmpty ? null : scope.Environments.ToList()
};
}
/// <summary>
/// Maps an ExceptionEvent to a DTO.
/// </summary>
public static ExceptionEventDto ToEventDto(ExceptionEvent evt)
{
return new ExceptionEventDto
{
EventId = evt.EventId,
SequenceNumber = evt.SequenceNumber,
EventType = EventTypeToString(evt.EventType),
ActorId = evt.ActorId,
OccurredAt = evt.OccurredAt,
PreviousStatus = evt.PreviousStatus.HasValue ? StatusToString(evt.PreviousStatus.Value) : null,
NewStatus = StatusToString(evt.NewStatus),
NewVersion = evt.NewVersion,
Description = evt.Description,
Details = evt.Details.IsEmpty ? null : evt.Details
};
}
/// <summary>
/// Maps an ExceptionHistory to a response DTO.
/// </summary>
public static ExceptionHistoryResponse ToHistoryResponse(ExceptionHistory history)
{
return new ExceptionHistoryResponse
{
ExceptionId = history.ExceptionId,
Events = history.Events.Select(ToEventDto).ToList(),
EventCount = history.EventCount,
FirstEventAt = history.FirstEventAt,
LastEventAt = history.LastEventAt
};
}
/// <summary>
/// Maps ExceptionCounts to a DTO.
/// </summary>
public static ExceptionCountsDto ToCountsDto(ExceptionCounts counts)
{
return new ExceptionCountsDto
{
Total = counts.Total,
Proposed = counts.Proposed,
Approved = counts.Approved,
Active = counts.Active,
Expired = counts.Expired,
Revoked = counts.Revoked,
ExpiringSoon = counts.ExpiringSoon
};
}
/// <summary>
/// Creates an ExceptionScope from a DTO.
/// </summary>
public static ExceptionScope FromScopeDto(ExceptionScopeDto dto, Guid? tenantId = null)
{
return new ExceptionScope
{
ArtifactDigest = dto.ArtifactDigest,
PurlPattern = dto.PurlPattern,
VulnerabilityId = dto.VulnerabilityId,
PolicyRuleId = dto.PolicyRuleId,
Environments = dto.Environments?.ToImmutableArray() ?? [],
TenantId = tenantId
};
}
/// <summary>
/// Creates an ExceptionObject from a create request.
/// </summary>
public static ExceptionObject FromCreateRequest(
CreateExceptionRequest request,
string exceptionId,
string ownerId,
string requesterId,
Guid? tenantId = null)
{
return new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ParseType(request.Type),
Scope = FromScopeDto(request.Scope, tenantId),
OwnerId = ownerId,
RequesterId = requesterId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReason(request.ReasonCode),
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
TicketRef = request.TicketRef
};
}
#region String Conversions
public static string StatusToString(ExceptionStatus status) => status switch
{
ExceptionStatus.Proposed => "proposed",
ExceptionStatus.Approved => "approved",
ExceptionStatus.Active => "active",
ExceptionStatus.Expired => "expired",
ExceptionStatus.Revoked => "revoked",
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
public static ExceptionStatus ParseStatus(string status) => status.ToLowerInvariant() switch
{
"proposed" => ExceptionStatus.Proposed,
"approved" => ExceptionStatus.Approved,
"active" => ExceptionStatus.Active,
"expired" => ExceptionStatus.Expired,
"revoked" => ExceptionStatus.Revoked,
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
public static string TypeToString(ExceptionType type) => type switch
{
ExceptionType.Vulnerability => "vulnerability",
ExceptionType.Policy => "policy",
ExceptionType.Unknown => "unknown",
ExceptionType.Component => "component",
_ => throw new ArgumentException($"Unknown type: {type}", nameof(type))
};
public static ExceptionType ParseType(string type) => type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => throw new ArgumentException($"Unknown type: {type}", nameof(type))
};
public static string ReasonToString(ExceptionReason reason) => reason switch
{
ExceptionReason.FalsePositive => "false_positive",
ExceptionReason.AcceptedRisk => "accepted_risk",
ExceptionReason.CompensatingControl => "compensating_control",
ExceptionReason.TestOnly => "test_only",
ExceptionReason.VendorNotAffected => "vendor_not_affected",
ExceptionReason.ScheduledFix => "scheduled_fix",
ExceptionReason.DeprecationInProgress => "deprecation_in_progress",
ExceptionReason.RuntimeMitigation => "runtime_mitigation",
ExceptionReason.NetworkIsolation => "network_isolation",
ExceptionReason.Other => "other",
_ => throw new ArgumentException($"Unknown reason: {reason}", nameof(reason))
};
public static ExceptionReason ParseReason(string reason) => reason.ToLowerInvariant() switch
{
"false_positive" => ExceptionReason.FalsePositive,
"accepted_risk" => ExceptionReason.AcceptedRisk,
"compensating_control" => ExceptionReason.CompensatingControl,
"test_only" => ExceptionReason.TestOnly,
"vendor_not_affected" => ExceptionReason.VendorNotAffected,
"scheduled_fix" => ExceptionReason.ScheduledFix,
"deprecation_in_progress" => ExceptionReason.DeprecationInProgress,
"runtime_mitigation" => ExceptionReason.RuntimeMitigation,
"network_isolation" => ExceptionReason.NetworkIsolation,
"other" => ExceptionReason.Other,
_ => throw new ArgumentException($"Unknown reason: {reason}", nameof(reason))
};
public static string EventTypeToString(ExceptionEventType eventType) => eventType switch
{
ExceptionEventType.Created => "created",
ExceptionEventType.Updated => "updated",
ExceptionEventType.Approved => "approved",
ExceptionEventType.Activated => "activated",
ExceptionEventType.Extended => "extended",
ExceptionEventType.Revoked => "revoked",
ExceptionEventType.Expired => "expired",
ExceptionEventType.EvidenceAttached => "evidence_attached",
ExceptionEventType.CompensatingControlAdded => "compensating_control_added",
ExceptionEventType.Rejected => "rejected",
_ => throw new ArgumentException($"Unknown event type: {eventType}", nameof(eventType))
};
#endregion
}

View File

@@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />