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:
@@ -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."
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user