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,395 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <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_WhenNoExceptionsFound_ShouldReturnNoMatch()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeFalse();
|
||||
result.MatchingExceptions.Should().BeEmpty();
|
||||
result.PrimaryReason.Should().BeNull();
|
||||
result.PrimaryRationale.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesVulnerability_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
reason: ExceptionReason.FalsePositive,
|
||||
rationale: "This is a false positive confirmed by manual analysis of the codebase.");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
result.MatchingExceptions.Should().HaveCount(1);
|
||||
result.PrimaryReason.Should().Be(ExceptionReason.FalsePositive);
|
||||
result.PrimaryRationale.Should().Contain("false positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesArtifactDigest_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:abc123def456";
|
||||
var exception = CreateException(artifactDigest: digest);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
ArtifactDigest = digest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
result.MatchingExceptions.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionMatchesPolicyRule_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(policyRuleId: "no-root-containers");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
PolicyRuleId = "no-root-containers"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasWrongVulnerabilityId_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(vulnerabilityId: "CVE-2024-99999");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasWrongArtifactDigest_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(artifactDigest: "sha256:wrongdigest");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
ArtifactDigest = "sha256:correctdigest"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenEnvironmentDoesNotMatch_ShouldNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
environments: ["staging", "dev"]);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
Environment = "prod"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenEnvironmentMatches_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
environments: ["staging", "dev", "prod"]);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
Environment = "prod"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenExceptionHasEmptyEnvironments_ShouldMatchAny()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
environments: []);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
Environment = "any-environment"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithMultipleMatchingExceptions_ShouldReturnMostSpecificFirst()
|
||||
{
|
||||
// Arrange
|
||||
var broadException = CreateException(
|
||||
exceptionId: "EXC-BROAD",
|
||||
vulnerabilityId: "CVE-2024-12345");
|
||||
|
||||
var specificException = CreateException(
|
||||
exceptionId: "EXC-SPECIFIC",
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
artifactDigest: "sha256:abc123");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([broadException, specificException]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
ArtifactDigest = "sha256:abc123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
result.MatchingExceptions.Should().HaveCount(2);
|
||||
// Most specific should be first (has more scope constraints)
|
||||
result.MatchingExceptions[0].ExceptionId.Should().Be("EXC-SPECIFIC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ShouldCollectAllEvidenceRefs()
|
||||
{
|
||||
// Arrange
|
||||
var exception1 = CreateException(
|
||||
exceptionId: "EXC-1",
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
evidenceRefs: ["sha256:evidence1"]);
|
||||
|
||||
var exception2 = CreateException(
|
||||
exceptionId: "EXC-2",
|
||||
vulnerabilityId: "CVE-2024-12345",
|
||||
evidenceRefs: ["sha256:evidence2", "sha256:evidence3"]);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception1, exception2]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.AllEvidenceRefs.Should().HaveCount(3);
|
||||
result.AllEvidenceRefs.Should().Contain(["sha256:evidence1", "sha256:evidence2", "sha256:evidence3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateBatchAsync_ShouldEvaluateAllContexts()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(vulnerabilityId: "CVE-2024-12345");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExceptionScope scope, CancellationToken _) =>
|
||||
scope.VulnerabilityId == "CVE-2024-12345" ? [exception] : []);
|
||||
|
||||
var contexts = new List<FindingContext>
|
||||
{
|
||||
new() { VulnerabilityId = "CVE-2024-12345" },
|
||||
new() { VulnerabilityId = "CVE-2024-99999" },
|
||||
new() { VulnerabilityId = "CVE-2024-12345" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await _evaluator.EvaluateBatchAsync(contexts);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(3);
|
||||
results[0].HasException.Should().BeTrue();
|
||||
results[1].HasException.Should().BeFalse();
|
||||
results[2].HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenPurlPatternMatchesExactly_ShouldReturnMatch()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(purlPattern: "pkg:npm/lodash@4.17.21");
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([exception]);
|
||||
|
||||
var context = new FindingContext
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _evaluator.EvaluateAsync(context);
|
||||
|
||||
// Assert
|
||||
result.HasException.Should().BeTrue();
|
||||
}
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string? exceptionId = null,
|
||||
string? vulnerabilityId = null,
|
||||
string? artifactDigest = null,
|
||||
string? policyRuleId = null,
|
||||
string? purlPattern = null,
|
||||
string[]? environments = null,
|
||||
ExceptionReason reason = ExceptionReason.AcceptedRisk,
|
||||
string? rationale = null,
|
||||
string[]? evidenceRefs = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId ?? $"EXC-{Guid.NewGuid():N}",
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Active,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
ArtifactDigest = artifactDigest,
|
||||
PolicyRuleId = policyRuleId,
|
||||
PurlPattern = purlPattern,
|
||||
Environments = environments?.ToImmutableArray() ?? []
|
||||
},
|
||||
OwnerId = "owner@example.com",
|
||||
RequesterId = "requester@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
ReasonCode = reason,
|
||||
Rationale = rationale ?? "This is a test rationale that meets the minimum character requirement of 50 characters.",
|
||||
EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [],
|
||||
CompensatingControls = []
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionEvent model and factory methods.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForCreated_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var actorId = "user@example.com";
|
||||
var description = "Test exception created";
|
||||
var clientInfo = "192.168.1.1";
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForCreated(exceptionId, actorId, description, clientInfo);
|
||||
|
||||
// Assert
|
||||
evt.ExceptionId.Should().Be(exceptionId);
|
||||
evt.ActorId.Should().Be(actorId);
|
||||
evt.Description.Should().Be(description);
|
||||
evt.ClientInfo.Should().Be(clientInfo);
|
||||
evt.EventType.Should().Be(ExceptionEventType.Created);
|
||||
evt.SequenceNumber.Should().Be(1);
|
||||
evt.PreviousStatus.Should().BeNull();
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
evt.NewVersion.Should().Be(1);
|
||||
evt.EventId.Should().NotBeEmpty();
|
||||
evt.OccurredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForCreated_WithoutDescription_ShouldUseDefault()
|
||||
{
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForCreated("EXC-TEST", "actor");
|
||||
|
||||
// Assert
|
||||
evt.Description.Should().Be("Exception created");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForApproved_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var sequenceNumber = 2;
|
||||
var actorId = "approver@example.com";
|
||||
var newVersion = 2;
|
||||
var description = "Approved by security team";
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForApproved(exceptionId, sequenceNumber, actorId, newVersion, description);
|
||||
|
||||
// Assert
|
||||
evt.EventType.Should().Be(ExceptionEventType.Approved);
|
||||
evt.ExceptionId.Should().Be(exceptionId);
|
||||
evt.SequenceNumber.Should().Be(sequenceNumber);
|
||||
evt.ActorId.Should().Be(actorId);
|
||||
evt.NewVersion.Should().Be(newVersion);
|
||||
evt.Description.Should().Be(description);
|
||||
evt.PreviousStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForApproved_WithoutDescription_ShouldIncludeActorId()
|
||||
{
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForApproved("EXC-TEST", 2, "approver@example.com", 2);
|
||||
|
||||
// Assert
|
||||
evt.Description.Should().Contain("approver@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForActivated_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var sequenceNumber = 3;
|
||||
var actorId = "admin@example.com";
|
||||
var newVersion = 3;
|
||||
var previousStatus = ExceptionStatus.Approved;
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForActivated(exceptionId, sequenceNumber, actorId, newVersion, previousStatus);
|
||||
|
||||
// Assert
|
||||
evt.EventType.Should().Be(ExceptionEventType.Activated);
|
||||
evt.PreviousStatus.Should().Be(ExceptionStatus.Approved);
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Active);
|
||||
evt.Description.Should().Be("Exception activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRevoked_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var sequenceNumber = 4;
|
||||
var actorId = "admin@example.com";
|
||||
var newVersion = 4;
|
||||
var previousStatus = ExceptionStatus.Active;
|
||||
var reason = "No longer needed";
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForRevoked(exceptionId, sequenceNumber, actorId, newVersion, previousStatus, reason);
|
||||
|
||||
// Assert
|
||||
evt.EventType.Should().Be(ExceptionEventType.Revoked);
|
||||
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Revoked);
|
||||
evt.Description.Should().Contain(reason);
|
||||
evt.Details.Should().ContainKey("reason");
|
||||
evt.Details["reason"].Should().Be(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForExpired_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var sequenceNumber = 5;
|
||||
var newVersion = 5;
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForExpired(exceptionId, sequenceNumber, newVersion);
|
||||
|
||||
// Assert
|
||||
evt.EventType.Should().Be(ExceptionEventType.Expired);
|
||||
evt.ActorId.Should().Be("system");
|
||||
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Expired);
|
||||
evt.Description.Should().Be("Exception expired automatically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForExtended_ShouldCreateCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exceptionId = "EXC-TEST123";
|
||||
var sequenceNumber = 6;
|
||||
var actorId = "admin@example.com";
|
||||
var newVersion = 6;
|
||||
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var newExpiry = DateTimeOffset.UtcNow.AddDays(30);
|
||||
var reason = "Extended due to ongoing dependency update";
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForExtended(exceptionId, sequenceNumber, actorId, newVersion, previousExpiry, newExpiry, reason);
|
||||
|
||||
// Assert
|
||||
evt.EventType.Should().Be(ExceptionEventType.Extended);
|
||||
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
|
||||
evt.NewStatus.Should().Be(ExceptionStatus.Active); // Status unchanged
|
||||
evt.Description.Should().Be(reason);
|
||||
evt.Details.Should().ContainKey("previous_expiry");
|
||||
evt.Details.Should().ContainKey("new_expiry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForExtended_WithoutReason_ShouldIncludeDates()
|
||||
{
|
||||
// Arrange
|
||||
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
|
||||
var newExpiry = DateTimeOffset.UtcNow.AddDays(30);
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForExtended("EXC-TEST", 2, "actor", 2, previousExpiry, newExpiry);
|
||||
|
||||
// Assert
|
||||
evt.Description.Should().Contain("extended from");
|
||||
evt.Description.Should().Contain("to");
|
||||
}
|
||||
|
||||
[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_AllValues_ShouldBeRecognized(ExceptionEventType eventType)
|
||||
{
|
||||
// Assert
|
||||
Enum.IsDefined(eventType).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFactoryMethods_ShouldGenerateUniqueEventIds()
|
||||
{
|
||||
// Act
|
||||
var events = new List<ExceptionEvent>
|
||||
{
|
||||
ExceptionEvent.ForCreated("EXC-1", "actor"),
|
||||
ExceptionEvent.ForCreated("EXC-2", "actor"),
|
||||
ExceptionEvent.ForApproved("EXC-1", 2, "actor", 2),
|
||||
ExceptionEvent.ForActivated("EXC-1", 3, "actor", 3, ExceptionStatus.Approved),
|
||||
ExceptionEvent.ForRevoked("EXC-1", 4, "actor", 4, ExceptionStatus.Active, "reason"),
|
||||
ExceptionEvent.ForExpired("EXC-1", 5, 5)
|
||||
};
|
||||
|
||||
// Assert
|
||||
events.Select(e => e.EventId).Distinct().Should().HaveCount(events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllFactoryMethods_ShouldSetOccurredAtToNow()
|
||||
{
|
||||
// Arrange
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var evt = ExceptionEvent.ForCreated("EXC-TEST", "actor");
|
||||
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
evt.OccurredAt.Should().BeOnOrAfter(before);
|
||||
evt.OccurredAt.Should().BeOnOrBefore(after);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionHistory aggregation.
|
||||
/// </summary>
|
||||
public sealed class ExceptionHistoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithEvents_ShouldCalculateCorrectStats()
|
||||
{
|
||||
// Arrange
|
||||
var events = new[]
|
||||
{
|
||||
CreateEvent(1, DateTimeOffset.UtcNow.AddHours(-3)),
|
||||
CreateEvent(2, DateTimeOffset.UtcNow.AddHours(-2)),
|
||||
CreateEvent(3, DateTimeOffset.UtcNow.AddHours(-1))
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var history = new ExceptionHistory
|
||||
{
|
||||
ExceptionId = "EXC-TEST",
|
||||
Events = events
|
||||
};
|
||||
|
||||
// Assert
|
||||
history.EventCount.Should().Be(3);
|
||||
history.FirstEventAt.Should().Be(events[0].OccurredAt);
|
||||
history.LastEventAt.Should().Be(events[2].OccurredAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithNoEvents_ShouldReturnNullTimestamps()
|
||||
{
|
||||
// Arrange & Act
|
||||
var history = new ExceptionHistory
|
||||
{
|
||||
ExceptionId = "EXC-TEST",
|
||||
Events = []
|
||||
};
|
||||
|
||||
// Assert
|
||||
history.EventCount.Should().Be(0);
|
||||
history.FirstEventAt.Should().BeNull();
|
||||
history.LastEventAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionHistory_WithSingleEvent_ShouldHaveSameFirstAndLast()
|
||||
{
|
||||
// Arrange
|
||||
var evt = CreateEvent(1, DateTimeOffset.UtcNow);
|
||||
var history = new ExceptionHistory
|
||||
{
|
||||
ExceptionId = "EXC-TEST",
|
||||
Events = [evt]
|
||||
};
|
||||
|
||||
// Assert
|
||||
history.EventCount.Should().Be(1);
|
||||
history.FirstEventAt.Should().Be(evt.OccurredAt);
|
||||
history.LastEventAt.Should().Be(evt.OccurredAt);
|
||||
}
|
||||
|
||||
private static ExceptionEvent CreateEvent(int sequenceNumber, DateTimeOffset occurredAt)
|
||||
{
|
||||
return new ExceptionEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = "EXC-TEST",
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Updated,
|
||||
ActorId = "actor",
|
||||
OccurredAt = occurredAt,
|
||||
NewStatus = ExceptionStatus.Active,
|
||||
NewVersion = sequenceNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionObject domain model.
|
||||
/// </summary>
|
||||
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_WithMetadata_ShouldStoreKeyValuePairs()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ImmutableDictionary<string, string>.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<string>? approverIds = null,
|
||||
ImmutableArray<string>? evidenceRefs = null,
|
||||
ImmutableDictionary<string, string>? metadata = 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<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Policy.Exceptions.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user