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,466 @@
// <copyright file="ExceptionContracts.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Gateway.Contracts;
/// <summary>
/// Request to create a new exception.
/// </summary>
public sealed record CreateExceptionRequest
{
/// <summary>
/// Type of exception (vulnerability, policy, unknown, component).
/// </summary>
[Required]
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope defining what this exception applies to.
/// </summary>
[Required]
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID (user or team accountable).
/// </summary>
[Required]
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Reason code for the exception.
/// </summary>
[Required]
[JsonPropertyName("reasonCode")]
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.")]
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// When the exception should expire.
/// </summary>
[Required]
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Content-addressed evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls in place.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// External ticket reference (e.g., JIRA-1234).
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Exception scope DTO.
/// </summary>
public sealed record ExceptionScopeDto
{
/// <summary>
/// Specific artifact digest (sha256:...).
/// </summary>
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
/// <summary>
/// PURL pattern (supports wildcards: pkg:npm/lodash@*).
/// </summary>
[JsonPropertyName("purlPattern")]
public string? PurlPattern { get; init; }
/// <summary>
/// Specific vulnerability ID (CVE-XXXX-XXXXX).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// Policy rule identifier to bypass.
/// </summary>
[JsonPropertyName("policyRuleId")]
public string? PolicyRuleId { get; init; }
/// <summary>
/// Environments where exception is valid (empty = all).
/// </summary>
[JsonPropertyName("environments")]
public IReadOnlyList<string>? Environments { get; init; }
}
/// <summary>
/// Request to update an exception.
/// </summary>
public sealed record UpdateExceptionRequest
{
/// <summary>
/// Updated rationale.
/// </summary>
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Updated evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Updated compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Updated ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Updated metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveExceptionRequest
{
/// <summary>
/// Optional comment from approver.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// Request to extend an exception's expiry.
/// </summary>
public sealed record ExtendExceptionRequest
{
/// <summary>
/// New expiry date.
/// </summary>
[Required]
[JsonPropertyName("newExpiresAt")]
public required DateTimeOffset NewExpiresAt { get; init; }
/// <summary>
/// Reason for extension.
/// </summary>
[Required]
[MinLength(20, ErrorMessage = "Extension reason must be at least 20 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
public sealed record RevokeExceptionRequest
{
/// <summary>
/// Reason for revocation.
/// </summary>
[Required]
[MinLength(10, ErrorMessage = "Revocation reason must be at least 10 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Exception response DTO.
/// </summary>
public sealed record ExceptionResponse
{
/// <summary>
/// Unique exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[JsonPropertyName("version")]
public required int Version { get; init; }
/// <summary>
/// Current status.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Exception type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope.
/// </summary>
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID.
/// </summary>
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Requester ID.
/// </summary>
[JsonPropertyName("requesterId")]
public required string RequesterId { get; init; }
/// <summary>
/// Approver IDs.
/// </summary>
[JsonPropertyName("approverIds")]
public required IReadOnlyList<string> ApproverIds { get; init; }
/// <summary>
/// Created timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last updated timestamp.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Approved timestamp.
/// </summary>
[JsonPropertyName("approvedAt")]
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>
/// Expiry timestamp.
/// </summary>
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Reason code.
/// </summary>
[JsonPropertyName("reasonCode")]
public required string ReasonCode { get; init; }
/// <summary>
/// Rationale.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public required IReadOnlyList<string> EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public required IReadOnlyList<string> CompensatingControls { get; init; }
/// <summary>
/// Ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Metadata.
/// </summary>
[JsonPropertyName("metadata")]
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
}
/// <summary>
/// Paginated list of exceptions.
/// </summary>
public sealed record ExceptionListResponse
{
/// <summary>
/// List of exceptions.
/// </summary>
[JsonPropertyName("items")]
public required IReadOnlyList<ExceptionResponse> Items { get; init; }
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("totalCount")]
public required int TotalCount { get; init; }
/// <summary>
/// Offset.
/// </summary>
[JsonPropertyName("offset")]
public required int Offset { get; init; }
/// <summary>
/// Limit.
/// </summary>
[JsonPropertyName("limit")]
public required int Limit { get; init; }
}
/// <summary>
/// Exception event DTO.
/// </summary>
public sealed record ExceptionEventDto
{
/// <summary>
/// Event ID.
/// </summary>
[JsonPropertyName("eventId")]
public required Guid EventId { get; init; }
/// <summary>
/// Sequence number.
/// </summary>
[JsonPropertyName("sequenceNumber")]
public required int SequenceNumber { get; init; }
/// <summary>
/// Event type.
/// </summary>
[JsonPropertyName("eventType")]
public required string EventType { get; init; }
/// <summary>
/// Actor ID.
/// </summary>
[JsonPropertyName("actorId")]
public required string ActorId { get; init; }
/// <summary>
/// Occurred timestamp.
/// </summary>
[JsonPropertyName("occurredAt")]
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Previous status.
/// </summary>
[JsonPropertyName("previousStatus")]
public string? PreviousStatus { get; init; }
/// <summary>
/// New status.
/// </summary>
[JsonPropertyName("newStatus")]
public required string NewStatus { get; init; }
/// <summary>
/// Description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Exception history response.
/// </summary>
public sealed record ExceptionHistoryResponse
{
/// <summary>
/// Exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Events in chronological order.
/// </summary>
[JsonPropertyName("events")]
public required IReadOnlyList<ExceptionEventDto> Events { get; init; }
}
/// <summary>
/// Exception counts summary.
/// </summary>
public sealed record ExceptionCountsResponse
{
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Proposed count.
/// </summary>
[JsonPropertyName("proposed")]
public required int Proposed { get; init; }
/// <summary>
/// Approved count.
/// </summary>
[JsonPropertyName("approved")]
public required int Approved { get; init; }
/// <summary>
/// Active count.
/// </summary>
[JsonPropertyName("active")]
public required int Active { get; init; }
/// <summary>
/// Expired count.
/// </summary>
[JsonPropertyName("expired")]
public required int Expired { get; init; }
/// <summary>
/// Revoked count.
/// </summary>
[JsonPropertyName("revoked")]
public required int Revoked { get; init; }
/// <summary>
/// Count expiring within 7 days.
/// </summary>
[JsonPropertyName("expiringSoon")]
public required int ExpiringSoon { get; init; }
}