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,367 @@
using System.Collections.Immutable;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Exceptions.Services;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionEvaluator service.
/// </summary>
public sealed class ExceptionEvaluatorTests
{
private readonly Mock<IExceptionRepository> _repositoryMock;
private readonly ExceptionEvaluator _evaluator;
public ExceptionEvaluatorTests()
{
_repositoryMock = new Mock<IExceptionRepository>();
_evaluator = new ExceptionEvaluator(_repositoryMock.Object);
}
[Fact]
public async Task EvaluateAsync_WhenNoExceptions_ReturnsNoMatch()
{
SetupRepository([]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
Assert.Empty(result.MatchingExceptions);
}
[Fact]
public async Task EvaluateAsync_WhenMatchingActiveException_ReturnsMatch()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
Assert.Single(result.MatchingExceptions);
Assert.Equal(exception.ExceptionId, result.MatchingExceptions[0].ExceptionId);
}
[Fact]
public async Task EvaluateAsync_WhenExpiredException_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) // Expired
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenProposedException_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Status = ExceptionStatus.Proposed
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenVulnerabilityIdDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-99999" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenArtifactDigestMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123"
}
};
SetupRepository([exception]);
var context = new FindingContext { ArtifactDigest = "sha256:abc123" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenArtifactDigestDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123"
}
};
SetupRepository([exception]);
var context = new FindingContext { ArtifactDigest = "sha256:different" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenPurlPatternMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
}
};
SetupRepository([exception]);
var context = new FindingContext { Purl = "pkg:npm/lodash@4.17.21" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenPurlPatternDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
}
};
SetupRepository([exception]);
var context = new FindingContext { Purl = "pkg:npm/axios@1.0.0" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["staging", "dev"]
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "dev"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["staging", "dev"]
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEmptyEnvironments_MatchesAnyEnvironment()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = [] // Empty means all environments
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_ReturnsEvidenceRefsFromMatchingExceptions()
{
var exception = CreateActiveException() with
{
EvidenceRefs = ["sha256:evidence1", "sha256:evidence2"]
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(2, result.AllEvidenceRefs.Count);
Assert.Contains("sha256:evidence1", result.AllEvidenceRefs);
}
[Fact]
public async Task EvaluateAsync_ReturnsPrimaryReasonFromMostSpecificMatch()
{
var exception = CreateActiveException() with
{
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This is a false positive because..."
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(ExceptionReason.FalsePositive, result.PrimaryReason);
Assert.Equal("This is a false positive because...", result.PrimaryRationale);
}
[Fact]
public async Task EvaluateAsync_MultipleMatches_SortsbySpecificity()
{
// More specific exception (has artifact digest)
var specificException = CreateActiveException("EXC-SPECIFIC") with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123",
VulnerabilityId = "CVE-2024-12345"
}
};
// Less specific exception (only vuln ID)
var generalException = CreateActiveException("EXC-GENERAL") with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
}
};
SetupRepository([generalException, specificException]);
var context = new FindingContext
{
ArtifactDigest = "sha256:abc123",
VulnerabilityId = "CVE-2024-12345"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(2, result.MatchingExceptions.Count);
// Most specific should be first
Assert.Equal("EXC-SPECIFIC", result.MatchingExceptions[0].ExceptionId);
}
[Fact]
public async Task EvaluateBatchAsync_EvaluatesAllContexts()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var contexts = new List<FindingContext>
{
new() { VulnerabilityId = "CVE-2024-12345" },
new() { VulnerabilityId = "CVE-2024-99999" },
new() { VulnerabilityId = "CVE-2024-12345" }
};
var results = await _evaluator.EvaluateBatchAsync(contexts);
Assert.Equal(3, results.Count);
Assert.True(results[0].HasException); // Matches
Assert.False(results[1].HasException); // No match
Assert.True(results[2].HasException); // Matches
}
[Fact]
public async Task EvaluateAsync_PolicyRuleMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Type = ExceptionType.Policy,
Scope = new ExceptionScope
{
PolicyRuleId = "NO-CRITICAL-VULNS"
}
};
SetupRepository([exception]);
var context = new FindingContext { PolicyRuleId = "NO-CRITICAL-VULNS" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
private void SetupRepository(IReadOnlyList<ExceptionObject> exceptions)
{
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(exceptions);
}
private static ExceptionObject CreateActiveException(string id = "EXC-TEST-001") => new()
{
ExceptionId = id,
Version = 1,
Status = ExceptionStatus.Active,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-7),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability does not affect our deployment because we don't use the affected feature."
};
}

View File

@@ -0,0 +1,162 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionEvent model and factory methods.
/// </summary>
public sealed class ExceptionEventTests
{
private const string TestExceptionId = "EXC-TEST-001";
private const string TestActorId = "user@example.com";
[Fact]
public void ForCreated_CreatesValidCreatedEvent()
{
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId);
Assert.Equal(TestExceptionId, evt.ExceptionId);
Assert.Equal(1, evt.SequenceNumber);
Assert.Equal(ExceptionEventType.Created, evt.EventType);
Assert.Equal(TestActorId, evt.ActorId);
Assert.Null(evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Proposed, evt.NewStatus);
Assert.Equal(1, evt.NewVersion);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.True(evt.OccurredAt <= DateTimeOffset.UtcNow);
}
[Fact]
public void ForCreated_WithDescription_IncludesDescription()
{
var description = "Custom creation description";
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId, description);
Assert.Equal(description, evt.Description);
}
[Fact]
public void ForCreated_WithClientInfo_IncludesClientInfo()
{
var clientInfo = "192.168.1.1";
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId, clientInfo: clientInfo);
Assert.Equal(clientInfo, evt.ClientInfo);
}
[Fact]
public void ForApproved_CreatesValidApprovedEvent()
{
var evt = ExceptionEvent.ForApproved(
TestExceptionId,
sequenceNumber: 2,
TestActorId,
newVersion: 2);
Assert.Equal(TestExceptionId, evt.ExceptionId);
Assert.Equal(2, evt.SequenceNumber);
Assert.Equal(ExceptionEventType.Approved, evt.EventType);
Assert.Equal(TestActorId, evt.ActorId);
Assert.Equal(ExceptionStatus.Proposed, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Approved, evt.NewStatus);
Assert.Equal(2, evt.NewVersion);
}
[Fact]
public void ForActivated_CreatesValidActivatedEvent()
{
var evt = ExceptionEvent.ForActivated(
TestExceptionId,
sequenceNumber: 3,
TestActorId,
newVersion: 3,
previousStatus: ExceptionStatus.Approved);
Assert.Equal(ExceptionEventType.Activated, evt.EventType);
Assert.Equal(ExceptionStatus.Approved, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Active, evt.NewStatus);
}
[Fact]
public void ForRevoked_CreatesValidRevokedEvent()
{
var reason = "No longer needed";
var evt = ExceptionEvent.ForRevoked(
TestExceptionId,
sequenceNumber: 4,
TestActorId,
newVersion: 4,
previousStatus: ExceptionStatus.Active,
reason: reason);
Assert.Equal(ExceptionEventType.Revoked, evt.EventType);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Revoked, evt.NewStatus);
Assert.Contains(reason, evt.Description);
Assert.True(evt.Details.ContainsKey("reason"));
Assert.Equal(reason, evt.Details["reason"]);
}
[Fact]
public void ForExpired_CreatesValidExpiredEvent()
{
var evt = ExceptionEvent.ForExpired(
TestExceptionId,
sequenceNumber: 5,
newVersion: 5);
Assert.Equal(ExceptionEventType.Expired, evt.EventType);
Assert.Equal("system", evt.ActorId);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Expired, evt.NewStatus);
}
[Fact]
public void ForExtended_CreatesValidExtendedEvent()
{
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
var newExpiry = DateTimeOffset.UtcNow.AddDays(37);
var evt = ExceptionEvent.ForExtended(
TestExceptionId,
sequenceNumber: 6,
TestActorId,
newVersion: 6,
previousExpiry,
newExpiry);
Assert.Equal(ExceptionEventType.Extended, evt.EventType);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Active, evt.NewStatus);
Assert.True(evt.Details.ContainsKey("previous_expiry"));
Assert.True(evt.Details.ContainsKey("new_expiry"));
}
[Theory]
[InlineData(ExceptionEventType.Created)]
[InlineData(ExceptionEventType.Updated)]
[InlineData(ExceptionEventType.Approved)]
[InlineData(ExceptionEventType.Activated)]
[InlineData(ExceptionEventType.Extended)]
[InlineData(ExceptionEventType.Revoked)]
[InlineData(ExceptionEventType.Expired)]
[InlineData(ExceptionEventType.EvidenceAttached)]
[InlineData(ExceptionEventType.CompensatingControlAdded)]
[InlineData(ExceptionEventType.Rejected)]
public void ExceptionEventType_HasAllExpectedValues(ExceptionEventType eventType)
{
// Verify all event types are defined
Assert.True(Enum.IsDefined(eventType));
}
[Fact]
public void ExceptionEvent_DetailsAreImmutable()
{
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId);
// Details should be an ImmutableDictionary
Assert.IsType<ImmutableDictionary<string, string>>(evt.Details);
}
}

View File

@@ -0,0 +1,153 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionHistory model.
/// </summary>
public sealed class ExceptionHistoryTests
{
private const string TestExceptionId = "EXC-TEST-001";
private const string TestActorId = "user@example.com";
[Fact]
public void ExceptionHistory_WithEvents_ReturnsCorrectCount()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.Equal(3, history.EventCount);
}
[Fact]
public void ExceptionHistory_Empty_ReturnsZeroCount()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Equal(0, history.EventCount);
}
[Fact]
public void FirstEventAt_WithEvents_ReturnsFirstEventTime()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.NotNull(history.FirstEventAt);
Assert.Equal(events[0].OccurredAt, history.FirstEventAt);
}
[Fact]
public void FirstEventAt_Empty_ReturnsNull()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Null(history.FirstEventAt);
}
[Fact]
public void LastEventAt_WithEvents_ReturnsLastEventTime()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.NotNull(history.LastEventAt);
Assert.Equal(events[^1].OccurredAt, history.LastEventAt);
}
[Fact]
public void LastEventAt_Empty_ReturnsNull()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Null(history.LastEventAt);
}
[Fact]
public void ExceptionHistory_PreservesEventOrder()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
// Events should be in chronological order by sequence number
for (int i = 0; i < history.Events.Length - 1; i++)
{
Assert.True(history.Events[i].SequenceNumber < history.Events[i + 1].SequenceNumber);
}
}
private static ImmutableArray<ExceptionEvent> CreateEventSequence()
{
var baseTime = DateTimeOffset.UtcNow.AddHours(-2);
return
[
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 1,
EventType = ExceptionEventType.Created,
ActorId = TestActorId,
OccurredAt = baseTime,
PreviousStatus = null,
NewStatus = ExceptionStatus.Proposed,
NewVersion = 1
},
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 2,
EventType = ExceptionEventType.Approved,
ActorId = "approver@example.com",
OccurredAt = baseTime.AddHours(1),
PreviousStatus = ExceptionStatus.Proposed,
NewStatus = ExceptionStatus.Approved,
NewVersion = 2
},
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 3,
EventType = ExceptionEventType.Activated,
ActorId = "approver@example.com",
OccurredAt = baseTime.AddHours(2),
PreviousStatus = ExceptionStatus.Approved,
NewStatus = ExceptionStatus.Active,
NewVersion = 3
}
];
}
}

View File

@@ -0,0 +1,271 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionObject domain model.
/// </summary>
public sealed class ExceptionObjectTests
{
[Fact]
public void ExceptionObject_WithRequiredFields_IsValid()
{
var exception = CreateValidException();
Assert.Equal("EXC-TEST-001", exception.ExceptionId);
Assert.Equal(1, exception.Version);
Assert.Equal(ExceptionStatus.Proposed, exception.Status);
Assert.Equal(ExceptionType.Vulnerability, exception.Type);
Assert.Equal("owner@example.com", exception.OwnerId);
Assert.Equal("requester@example.com", exception.RequesterId);
}
[Fact]
public void ExceptionScope_WithVulnerabilityId_IsValid()
{
var scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithArtifactDigest_IsValid()
{
var scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123def456"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithPurlPattern_IsValid()
{
var scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithPolicyRuleId_IsValid()
{
var scope = new ExceptionScope
{
PolicyRuleId = "POLICY-NO-CRITICAL"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_Empty_IsNotValid()
{
var scope = new ExceptionScope();
Assert.False(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithOnlyEnvironments_IsNotValid()
{
var scope = new ExceptionScope
{
Environments = ["prod", "staging"]
};
Assert.False(scope.IsValid);
}
[Fact]
public void IsEffective_WhenActiveAndNotExpired_ReturnsTrue()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.True(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenActiveButExpired_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenProposed_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Proposed,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenRevoked_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Revoked,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void HasExpired_WhenPastExpiresAt_ReturnsTrue()
{
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
};
Assert.True(exception.HasExpired);
}
[Fact]
public void HasExpired_WhenBeforeExpiresAt_ReturnsFalse()
{
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.HasExpired);
}
[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 ExceptionObject_SupportsAllReasonCodes(ExceptionReason reason)
{
var exception = CreateValidException() with
{
ReasonCode = reason
};
Assert.Equal(reason, exception.ReasonCode);
}
[Theory]
[InlineData(ExceptionType.Vulnerability)]
[InlineData(ExceptionType.Policy)]
[InlineData(ExceptionType.Unknown)]
[InlineData(ExceptionType.Component)]
public void ExceptionObject_SupportsAllExceptionTypes(ExceptionType type)
{
var exception = CreateValidException() with
{
Type = type
};
Assert.Equal(type, exception.Type);
}
[Fact]
public void ExceptionObject_WithEvidenceRefs_PreservesRefs()
{
var refs = ImmutableArray.Create("sha256:evidence1", "sha256:evidence2");
var exception = CreateValidException() with
{
EvidenceRefs = refs
};
Assert.Equal(2, exception.EvidenceRefs.Length);
Assert.Contains("sha256:evidence1", exception.EvidenceRefs);
Assert.Contains("sha256:evidence2", exception.EvidenceRefs);
}
[Fact]
public void ExceptionObject_WithCompensatingControls_PreservesControls()
{
var controls = ImmutableArray.Create("WAF protection", "Rate limiting");
var exception = CreateValidException() with
{
CompensatingControls = controls
};
Assert.Equal(2, exception.CompensatingControls.Length);
Assert.Contains("WAF protection", exception.CompensatingControls);
}
[Fact]
public void ExceptionObject_WithMetadata_PreservesMetadata()
{
var metadata = ImmutableDictionary<string, string>.Empty
.Add("jira_ticket", "SEC-1234")
.Add("risk_owner", "security-team");
var exception = CreateValidException() with
{
Metadata = metadata
};
Assert.Equal(2, exception.Metadata.Count);
Assert.Equal("SEC-1234", exception.Metadata["jira_ticket"]);
}
[Fact]
public void ExceptionObject_WithApprovers_PreservesApproverIds()
{
var approvers = ImmutableArray.Create("approver1@example.com", "approver2@example.com");
var exception = CreateValidException() with
{
ApproverIds = approvers,
ApprovedAt = DateTimeOffset.UtcNow,
Status = ExceptionStatus.Approved
};
Assert.Equal(2, exception.ApproverIds.Length);
Assert.Contains("approver1@example.com", exception.ApproverIds);
}
private static ExceptionObject CreateValidException() => new()
{
ExceptionId = "EXC-TEST-001",
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["prod"]
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(90),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability does not affect our deployment because we don't use the affected feature."
};
}