using System.Collections.Immutable; using FluentAssertions; using StellaOps.Policy.Exceptions.Models; using Xunit; namespace StellaOps.Policy.Exceptions.Tests; /// /// Unit tests for ExceptionObject domain model. /// public sealed class ExceptionObjectTests { [Fact] public void ExceptionObject_WithValidScope_ShouldBeValid() { // Arrange & Act var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" }; // Assert scope.IsValid.Should().BeTrue(); } [Fact] public void ExceptionScope_WithNoConstraints_ShouldBeInvalid() { // Arrange & Act var scope = new ExceptionScope(); // Assert scope.IsValid.Should().BeFalse(); } [Fact] public void ExceptionScope_WithArtifactDigest_ShouldBeValid() { // Arrange & Act var scope = new ExceptionScope { ArtifactDigest = "sha256:abc123def456" }; // Assert scope.IsValid.Should().BeTrue(); } [Fact] public void ExceptionScope_WithPurlPattern_ShouldBeValid() { // Arrange & Act var scope = new ExceptionScope { PurlPattern = "pkg:npm/lodash@*" }; // Assert scope.IsValid.Should().BeTrue(); } [Fact] public void ExceptionScope_WithPolicyRuleId_ShouldBeValid() { // Arrange & Act var scope = new ExceptionScope { PolicyRuleId = "no-root-containers" }; // Assert scope.IsValid.Should().BeTrue(); } [Fact] public void ExceptionObject_IsEffective_WhenActiveAndNotExpired_ShouldBeTrue() { // Arrange var exception = CreateException( status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(30)); // Act & Assert exception.IsEffective.Should().BeTrue(); exception.HasExpired.Should().BeFalse(); } [Fact] public void ExceptionObject_IsEffective_WhenActiveButExpired_ShouldBeFalse() { // Arrange var exception = CreateException( status: ExceptionStatus.Active, expiresAt: DateTimeOffset.UtcNow.AddDays(-1)); // Act & Assert exception.IsEffective.Should().BeFalse(); exception.HasExpired.Should().BeTrue(); } [Fact] public void ExceptionObject_IsEffective_WhenProposed_ShouldBeFalse() { // Arrange var exception = CreateException( status: ExceptionStatus.Proposed, expiresAt: DateTimeOffset.UtcNow.AddDays(30)); // Act & Assert exception.IsEffective.Should().BeFalse(); } [Fact] public void ExceptionObject_IsEffective_WhenRevoked_ShouldBeFalse() { // Arrange var exception = CreateException( status: ExceptionStatus.Revoked, expiresAt: DateTimeOffset.UtcNow.AddDays(30)); // Act & Assert exception.IsEffective.Should().BeFalse(); } [Fact] public void ExceptionObject_IsEffective_WhenExpiredStatus_ShouldBeFalse() { // Arrange var exception = CreateException( status: ExceptionStatus.Expired, expiresAt: DateTimeOffset.UtcNow.AddDays(-1)); // Act & Assert exception.IsEffective.Should().BeFalse(); } [Theory] [InlineData(ExceptionStatus.Proposed)] [InlineData(ExceptionStatus.Approved)] [InlineData(ExceptionStatus.Active)] [InlineData(ExceptionStatus.Expired)] [InlineData(ExceptionStatus.Revoked)] public void ExceptionStatus_AllValues_ShouldBeRecognized(ExceptionStatus status) { // Arrange var exception = CreateException(status: status); // Assert exception.Status.Should().Be(status); } [Theory] [InlineData(ExceptionType.Vulnerability)] [InlineData(ExceptionType.Policy)] [InlineData(ExceptionType.Unknown)] [InlineData(ExceptionType.Component)] public void ExceptionType_AllValues_ShouldBeRecognized(ExceptionType type) { // Arrange var exception = CreateException(type: type); // Assert exception.Type.Should().Be(type); } [Theory] [InlineData(ExceptionReason.FalsePositive)] [InlineData(ExceptionReason.AcceptedRisk)] [InlineData(ExceptionReason.CompensatingControl)] [InlineData(ExceptionReason.TestOnly)] [InlineData(ExceptionReason.VendorNotAffected)] [InlineData(ExceptionReason.ScheduledFix)] [InlineData(ExceptionReason.DeprecationInProgress)] [InlineData(ExceptionReason.RuntimeMitigation)] [InlineData(ExceptionReason.NetworkIsolation)] [InlineData(ExceptionReason.Other)] public void ExceptionReason_AllValues_ShouldBeRecognized(ExceptionReason reason) { // Arrange var exception = CreateException(reason: reason); // Assert exception.ReasonCode.Should().Be(reason); } [Fact] public void ExceptionObject_WithMultipleApprovers_ShouldStoreAll() { // Arrange var approvers = ImmutableArray.Create("approver1", "approver2", "approver3"); var exception = CreateException(approverIds: approvers); // Assert exception.ApproverIds.Should().HaveCount(3); exception.ApproverIds.Should().Contain(["approver1", "approver2", "approver3"]); } [Fact] public void ExceptionObject_WithEvidenceRefs_ShouldStoreAll() { // Arrange var evidenceRefs = ImmutableArray.Create( "sha256:evidence1hash", "sha256:evidence2hash"); var exception = CreateException(evidenceRefs: evidenceRefs); // Assert exception.EvidenceRefs.Should().HaveCount(2); exception.EvidenceRefs.Should().Contain("sha256:evidence1hash"); } [Fact] public void ExceptionObject_IsBlockedByRecheck_WhenBlockTriggered_ShouldBeTrue() { // Arrange var exception = CreateException(recheckResult: new RecheckEvaluationResult { IsTriggered = true, TriggeredConditions = [], RecommendedAction = RecheckAction.Block, EvaluatedAt = DateTimeOffset.UtcNow }); // Act & Assert exception.IsBlockedByRecheck.Should().BeTrue(); exception.RequiresReapproval.Should().BeFalse(); } [Fact] public void ExceptionObject_RequiresReapproval_WhenReapprovalTriggered_ShouldBeTrue() { // Arrange var exception = CreateException(recheckResult: new RecheckEvaluationResult { IsTriggered = true, TriggeredConditions = [], RecommendedAction = RecheckAction.RequireReapproval, EvaluatedAt = DateTimeOffset.UtcNow }); // Act & Assert exception.RequiresReapproval.Should().BeTrue(); exception.IsBlockedByRecheck.Should().BeFalse(); } [Fact] public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs() { // Arrange var metadata = ImmutableDictionary.Empty .Add("team", "security") .Add("priority", "high"); var exception = CreateException(metadata: metadata); // Assert exception.Metadata.Should().HaveCount(2); exception.Metadata["team"].Should().Be("security"); exception.Metadata["priority"].Should().Be("high"); } [Fact] public void ExceptionScope_WithEnvironments_ShouldStoreAll() { // Arrange var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345", Environments = ["prod", "staging", "dev"] }; // Assert scope.Environments.Should().HaveCount(3); scope.Environments.Should().Contain(["prod", "staging", "dev"]); } [Fact] public void ExceptionScope_WithTenantId_ShouldStoreValue() { // Arrange var tenantId = Guid.NewGuid(); var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345", TenantId = tenantId }; // Assert scope.TenantId.Should().Be(tenantId); } #region Test Helpers private static ExceptionObject CreateException( ExceptionStatus status = ExceptionStatus.Active, ExceptionType type = ExceptionType.Vulnerability, ExceptionReason reason = ExceptionReason.AcceptedRisk, DateTimeOffset? expiresAt = null, ImmutableArray? approverIds = null, ImmutableArray? evidenceRefs = null, ImmutableDictionary? metadata = null, RecheckEvaluationResult? recheckResult = null) { return new ExceptionObject { ExceptionId = $"EXC-{Guid.NewGuid():N}", Version = 1, Status = status, Type = type, Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" }, OwnerId = "owner@example.com", RequesterId = "requester@example.com", ApproverIds = approverIds ?? [], CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30), ReasonCode = reason, Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.", EvidenceRefs = evidenceRefs ?? [], CompensatingControls = [], Metadata = metadata ?? ImmutableDictionary.Empty, LastRecheckResult = recheckResult, LastRecheckAt = recheckResult?.EvaluatedAt }; } #endregion }