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>
|
||||
@@ -0,0 +1,300 @@
|
||||
// <copyright file="ApprovalWorkflowServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MsOptions = Microsoft.Extensions.Options.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests.Services;
|
||||
|
||||
public class ApprovalWorkflowServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IExceptionNotificationService> _notificationMock;
|
||||
private readonly ApprovalWorkflowOptions _options;
|
||||
private readonly ApprovalWorkflowService _service;
|
||||
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public ApprovalWorkflowServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(_now);
|
||||
_notificationMock = new Mock<IExceptionNotificationService>();
|
||||
_options = new ApprovalWorkflowOptions();
|
||||
_service = new ApprovalWorkflowService(
|
||||
MsOptions.Create(_options),
|
||||
_timeProvider,
|
||||
_notificationMock.Object,
|
||||
NullLogger<ApprovalWorkflowService>.Instance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dev")]
|
||||
[InlineData("Dev")]
|
||||
[InlineData("DEV")]
|
||||
public void GetPolicyForEnvironment_Dev_ReturnsDevPolicy(string env)
|
||||
{
|
||||
// Act
|
||||
var policy = _service.GetPolicyForEnvironment(env);
|
||||
|
||||
// Assert
|
||||
policy.RequiredApprovers.Should().Be(0);
|
||||
policy.RequesterCanApprove.Should().BeTrue();
|
||||
policy.AutoApprove.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPolicyForEnvironment_Staging_ReturnsStagingPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = _service.GetPolicyForEnvironment("staging");
|
||||
|
||||
// Assert
|
||||
policy.RequiredApprovers.Should().Be(1);
|
||||
policy.RequesterCanApprove.Should().BeFalse();
|
||||
policy.AutoApprove.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPolicyForEnvironment_Prod_ReturnsProdPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = _service.GetPolicyForEnvironment("prod");
|
||||
|
||||
// Assert
|
||||
policy.RequiredApprovers.Should().Be(2);
|
||||
policy.RequesterCanApprove.Should().BeFalse();
|
||||
policy.AllowedApproverRoles.Should().Contain("security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPolicyForEnvironment_Unknown_ReturnsDefaultPolicy()
|
||||
{
|
||||
// Act
|
||||
var policy = _service.GetPolicyForEnvironment("unknown-env");
|
||||
|
||||
// Assert
|
||||
policy.Should().Be(_options.DefaultPolicy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_SelfApprovalInProd_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "prod");
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "requester-123");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("cannot approve their own");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_SelfApprovalInDev_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "dev");
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "requester-123");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.IsComplete.Should().BeTrue(); // Dev requires 0 approvers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_AlreadyApproved_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "staging") with
|
||||
{
|
||||
ApproverIds = ["approver-456"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "approver-456");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("already approved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_MissingRequiredRole_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "prod");
|
||||
var approverRoles = new List<string> { "developer" };
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_WithRequiredRole_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "prod");
|
||||
var approverRoles = new List<string> { "security-lead" };
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.IsComplete.Should().BeFalse(); // Prod requires 2 approvers
|
||||
result.ApprovalsRemaining.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_SecondApprovalInProd_ReturnsComplete()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "prod") with
|
||||
{
|
||||
ApproverIds = ["approver-111"]
|
||||
};
|
||||
var approverRoles = new List<string> { "security-admin" };
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "approver-222", approverRoles);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.ApprovalsRemaining.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApproval_ExpiredDeadline_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "staging") with
|
||||
{
|
||||
CreatedAt = _now.AddDays(-30) // Way past staging deadline of 14 days
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ValidateApproval(exception, "approver-456");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("deadline has passed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoApprove_DevEnvironment_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "dev");
|
||||
|
||||
// Act
|
||||
var result = _service.ShouldAutoApprove(exception);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoApprove_ProdEnvironment_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "prod");
|
||||
|
||||
// Act
|
||||
var result = _service.ShouldAutoApprove(exception);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsApprovalExpired_WithinDeadline_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "staging") with
|
||||
{
|
||||
CreatedAt = _now.AddDays(-5)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.IsApprovalExpired(exception);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsApprovalExpired_PastDeadline_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("requester-123", "staging") with
|
||||
{
|
||||
CreatedAt = _now.AddDays(-20)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.IsApprovalExpired(exception);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetApprovalDeadline_ReturnsCorrectDeadline()
|
||||
{
|
||||
// Arrange
|
||||
var createdAt = _now.AddDays(-5);
|
||||
var exception = CreateException("requester-123", "staging") with
|
||||
{
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
|
||||
// Act
|
||||
var deadline = _service.GetApprovalDeadline(exception);
|
||||
|
||||
// Assert
|
||||
deadline.Should().Be(createdAt.AddDays(14)); // Staging deadline is 14 days
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ExceptionObject CreateException(string requesterId, string environment) => new()
|
||||
{
|
||||
ExceptionId = "EXC-123",
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Proposed,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
Environments = [environment]
|
||||
},
|
||||
OwnerId = "owner-team",
|
||||
RequesterId = requesterId,
|
||||
CreatedAt = _now.AddDays(-1),
|
||||
UpdatedAt = _now.AddDays(-1),
|
||||
ExpiresAt = _now.AddDays(30),
|
||||
ReasonCode = ExceptionReason.FalsePositive,
|
||||
Rationale = "Test rationale",
|
||||
EvidenceRefs = [],
|
||||
CompensatingControls = [],
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// <copyright file="ExceptionServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests.Services;
|
||||
|
||||
public class ExceptionServiceTests
|
||||
{
|
||||
private readonly Mock<IExceptionRepository> _repositoryMock;
|
||||
private readonly Mock<IExceptionNotificationService> _notificationMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ExceptionService _service;
|
||||
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public ExceptionServiceTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IExceptionRepository>();
|
||||
_notificationMock = new Mock<IExceptionNotificationService>();
|
||||
_timeProvider = new FakeTimeProvider(_now);
|
||||
_service = new ExceptionService(
|
||||
_repositoryMock.Object,
|
||||
_notificationMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<ExceptionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidRequest_CreatesException()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<ExceptionObject>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExceptionObject ex, string _, string? _, CancellationToken _) => ex);
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(command, "user-123", "client-info");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Exception.Should().NotBeNull();
|
||||
result.Exception!.Status.Should().Be(ExceptionStatus.Proposed);
|
||||
result.Exception.RequesterId.Should().Be("user-123");
|
||||
result.Exception.OwnerId.Should().Be(command.OwnerId);
|
||||
result.Exception.Rationale.Should().Be(command.Rationale);
|
||||
|
||||
_notificationMock.Verify(n => n.NotifyExceptionCreatedAsync(It.IsAny<ExceptionObject>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEmptyScope_ReturnsValidationError()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand() with
|
||||
{
|
||||
Scope = new ExceptionScope()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(command, "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.ScopeNotSpecific);
|
||||
result.Error.Should().Contain("scope must specify at least one");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithPastExpiry_ReturnsValidationError()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand() with
|
||||
{
|
||||
ExpiresAt = _now.AddDays(-1)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(command, "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
|
||||
result.Error.Should().Contain("future");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithExpiryTooFar_ReturnsValidationError()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand() with
|
||||
{
|
||||
ExpiresAt = _now.AddDays(400) // More than 365 days
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(command, "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
|
||||
result.Error.Should().Contain("365 days");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithShortRationale_ReturnsValidationError()
|
||||
{
|
||||
// Arrange
|
||||
var command = CreateValidCommand() with
|
||||
{
|
||||
Rationale = "Too short"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(command, "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.RationaleTooShort);
|
||||
result.Error.Should().Contain("50 characters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_WhenSelfApproval_ReturnsSelfApprovalError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.ApproveAsync("EXC-123", null, "requester-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.SelfApprovalNotAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_WhenNotProposed_ReturnsInvalidStateError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.ApproveAsync("EXC-123", null, "approver-456");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_WithValidApprover_ApprovesException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
|
||||
|
||||
// Act
|
||||
var result = await _service.ApproveAsync("EXC-123", "Looks good", "approver-456");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Exception!.Status.Should().Be(ExceptionStatus.Approved);
|
||||
result.Exception.ApproverIds.Should().Contain("approver-456");
|
||||
|
||||
_notificationMock.Verify(n => n.NotifyExceptionApprovedAsync(It.IsAny<ExceptionObject>(), "approver-456", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WhenNotApproved_ReturnsInvalidStateError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.ActivateAsync("EXC-123", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WhenApproved_ActivatesException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Approved, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
|
||||
|
||||
// Act
|
||||
var result = await _service.ActivateAsync("EXC-123", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Exception!.Status.Should().Be(ExceptionStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_WhenAlreadyRevoked_ReturnsInvalidStateError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Revoked, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.RevokeAsync("EXC-123", "Not needed anymore", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeAsync_WithShortReason_ReturnsValidationError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.RevokeAsync("EXC-123", "Too short", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.ValidationFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendAsync_WhenNotActive_ReturnsInvalidStateError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(90), "Need more time to remediate", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendAsync_WhenNewExpiryBeforeCurrent_ReturnsInvalidExpiryError()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123") with
|
||||
{
|
||||
ExpiresAt = _now.AddDays(30)
|
||||
};
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exception);
|
||||
|
||||
// Act
|
||||
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(15), "Need more time to remediate", "user-123");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenNotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-999", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExceptionObject?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync("EXC-999");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private CreateExceptionCommand CreateValidCommand() => new()
|
||||
{
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = "CVE-2024-12345"
|
||||
},
|
||||
OwnerId = "owner-team",
|
||||
ReasonCode = ExceptionReason.FalsePositive,
|
||||
Rationale = "This vulnerability is a false positive because the vulnerable code path is not reachable in our deployment configuration.",
|
||||
ExpiresAt = _now.AddDays(30)
|
||||
};
|
||||
|
||||
private ExceptionObject CreateExceptionObject(ExceptionStatus status, string requesterId) => new()
|
||||
{
|
||||
ExceptionId = "EXC-123",
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" },
|
||||
OwnerId = "owner-team",
|
||||
RequesterId = requesterId,
|
||||
CreatedAt = _now.AddDays(-1),
|
||||
UpdatedAt = _now.AddDays(-1),
|
||||
ExpiresAt = _now.AddDays(30),
|
||||
ReasonCode = ExceptionReason.FalsePositive,
|
||||
Rationale = "Test rationale that is long enough to pass validation requirements.",
|
||||
EvidenceRefs = [],
|
||||
CompensatingControls = [],
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,9 +4,26 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresExceptionObjectRepository.
|
||||
/// Tests the new auditable exception objects against PostgreSQL.
|
||||
/// </summary>
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PostgresExceptionObjectRepository _repository;
|
||||
|
||||
public ExceptionObjectRepositoryTests(PolicyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
||||
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ShouldPersistExceptionAndCreateEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-CREATE-001");
|
||||
|
||||
// Act
|
||||
var created = await _repository.CreateAsync(exception, "test-actor", "127.0.0.1");
|
||||
|
||||
// Assert
|
||||
created.Should().NotBeNull();
|
||||
created.ExceptionId.Should().Be("EXC-CREATE-001");
|
||||
created.Version.Should().Be(1);
|
||||
|
||||
// Verify event was created
|
||||
var history = await _repository.GetHistoryAsync("EXC-CREATE-001");
|
||||
history.Events.Should().HaveCount(1);
|
||||
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
|
||||
history.Events[0].ActorId.Should().Be("test-actor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenExists_ShouldReturnException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-GETBYID-001");
|
||||
await _repository.CreateAsync(exception, "test-actor");
|
||||
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync("EXC-GETBYID-001");
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ExceptionId.Should().Be("EXC-GETBYID-001");
|
||||
fetched.Status.Should().Be(ExceptionStatus.Proposed);
|
||||
fetched.Type.Should().Be(ExceptionType.Vulnerability);
|
||||
fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345");
|
||||
fetched.OwnerId.Should().Be("owner@example.com");
|
||||
fetched.ReasonCode.Should().Be(ExceptionReason.AcceptedRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
|
||||
{
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ShouldIncrementVersionAndCreateEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-UPDATE-001");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
ApprovedAt = DateTimeOffset.UtcNow,
|
||||
ApproverIds = ["approver@example.com"],
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com", "Approved by security team");
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(2);
|
||||
result.Status.Should().Be(ExceptionStatus.Approved);
|
||||
|
||||
var fetched = await _repository.GetByIdAsync("EXC-UPDATE-001");
|
||||
fetched!.Version.Should().Be(2);
|
||||
fetched.Status.Should().Be(ExceptionStatus.Approved);
|
||||
|
||||
// Verify events
|
||||
var history = await _repository.GetHistoryAsync("EXC-UPDATE-001");
|
||||
history.Events.Should().HaveCount(2);
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithConcurrencyConflict_ShouldThrow()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-CONCURRENCY-001");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Simulate stale version
|
||||
var staleUpdate = exception with
|
||||
{
|
||||
Version = 5, // Wrong version - should be 2
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConcurrencyException>(() =>
|
||||
_repository.UpdateAsync(staleUpdate, ExceptionEventType.Updated, "updater"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var proposed = CreateException("EXC-FILTER-001", status: ExceptionStatus.Proposed);
|
||||
var active = CreateException("EXC-FILTER-002", status: ExceptionStatus.Active);
|
||||
var revoked = CreateException("EXC-FILTER-003", status: ExceptionStatus.Revoked);
|
||||
|
||||
await _repository.CreateAsync(proposed, "actor");
|
||||
await _repository.CreateAsync(active, "actor");
|
||||
await _repository.CreateAsync(revoked, "actor");
|
||||
|
||||
// Act
|
||||
var filter = new ExceptionFilter { Status = ExceptionStatus.Proposed };
|
||||
var results = await _repository.GetByFilterAsync(filter);
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-FILTER-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByType()
|
||||
{
|
||||
// Arrange
|
||||
var vuln = CreateException("EXC-TYPE-001", type: ExceptionType.Vulnerability);
|
||||
var policy = CreateException("EXC-TYPE-002", type: ExceptionType.Policy);
|
||||
|
||||
await _repository.CreateAsync(vuln, "actor");
|
||||
await _repository.CreateAsync(policy, "actor");
|
||||
|
||||
// Act
|
||||
var filter = new ExceptionFilter { Type = ExceptionType.Policy };
|
||||
var results = await _repository.GetByFilterAsync(filter);
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-TYPE-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldFilterByVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
var exc1 = CreateException("EXC-VID-001", vulnerabilityId: "CVE-2024-11111");
|
||||
var exc2 = CreateException("EXC-VID-002", vulnerabilityId: "CVE-2024-22222");
|
||||
|
||||
await _repository.CreateAsync(exc1, "actor");
|
||||
await _repository.CreateAsync(exc2, "actor");
|
||||
|
||||
// Act
|
||||
var filter = new ExceptionFilter { VulnerabilityId = "CVE-2024-11111" };
|
||||
var results = await _repository.GetByFilterAsync(filter);
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-VID-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_ShouldSupportPagination()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.CreateAsync(CreateException($"EXC-PAGE-{i:D3}"), "actor");
|
||||
}
|
||||
|
||||
// Act
|
||||
var filter = new ExceptionFilter { Limit = 2, Offset = 2 };
|
||||
var results = await _repository.GetByFilterAsync(filter);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ShouldMatchVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-SCOPE-001", vulnerabilityId: "CVE-2024-99999", status: ExceptionStatus.Active);
|
||||
await _repository.CreateAsync(exception, "actor");
|
||||
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-99999" };
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-SCOPE-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ShouldExcludeInactiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var proposed = CreateException("EXC-INACTIVE-001", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Proposed);
|
||||
var revoked = CreateException("EXC-INACTIVE-002", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Revoked);
|
||||
|
||||
await _repository.CreateAsync(proposed, "actor");
|
||||
await _repository.CreateAsync(revoked, "actor");
|
||||
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-88888" };
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiringAsync_ShouldReturnExceptionsExpiringSoon()
|
||||
{
|
||||
// Arrange
|
||||
var expiringSoon = CreateException("EXC-EXPIRING-001",
|
||||
status: ExceptionStatus.Active,
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(3));
|
||||
var expiringLater = CreateException("EXC-EXPIRING-002",
|
||||
status: ExceptionStatus.Active,
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
|
||||
|
||||
await _repository.CreateAsync(expiringSoon, "actor");
|
||||
await _repository.CreateAsync(expiringLater, "actor");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRING-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredActiveAsync_ShouldReturnExpiredButActiveExceptions()
|
||||
{
|
||||
// Arrange - Create with past expiry
|
||||
var expired = CreateException("EXC-EXPIRED-001",
|
||||
status: ExceptionStatus.Active,
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
|
||||
|
||||
await _repository.CreateAsync(expired, "actor");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiredActiveAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().ContainSingle();
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRED-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ShouldReturnEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException("EXC-HISTORY-001");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
ApprovedAt = DateTimeOffset.UtcNow,
|
||||
ApproverIds = ["approver@example.com"],
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver");
|
||||
|
||||
var activated = updated with
|
||||
{
|
||||
Version = 3,
|
||||
Status = ExceptionStatus.Active,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "activator");
|
||||
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync("EXC-HISTORY-001");
|
||||
|
||||
// Assert
|
||||
history.EventCount.Should().Be(3);
|
||||
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
history.Events[2].EventType.Should().Be(ExceptionEventType.Activated);
|
||||
history.Events[0].SequenceNumber.Should().Be(1);
|
||||
history.Events[1].SequenceNumber.Should().Be(2);
|
||||
history.Events[2].SequenceNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCountsAsync_ShouldReturnCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(CreateException("EXC-COUNT-001", status: ExceptionStatus.Proposed), "actor");
|
||||
await _repository.CreateAsync(CreateException("EXC-COUNT-002", status: ExceptionStatus.Proposed), "actor");
|
||||
await _repository.CreateAsync(CreateException("EXC-COUNT-003", status: ExceptionStatus.Active), "actor");
|
||||
await _repository.CreateAsync(CreateException("EXC-COUNT-004", status: ExceptionStatus.Revoked), "actor");
|
||||
await _repository.CreateAsync(CreateException("EXC-COUNT-005", status: ExceptionStatus.Active,
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(3)), "actor");
|
||||
|
||||
// Act
|
||||
var counts = await _repository.GetCountsAsync();
|
||||
|
||||
// Assert
|
||||
counts.Total.Should().Be(5);
|
||||
counts.Proposed.Should().Be(2);
|
||||
counts.Active.Should().Be(2);
|
||||
counts.Revoked.Should().Be(1);
|
||||
counts.ExpiringSoon.Should().BeGreaterOrEqualTo(1); // At least the one expiring in 3 days
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithMetadata_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("team", "security")
|
||||
.Add("priority", "high")
|
||||
.Add("ticket", "SEC-123");
|
||||
|
||||
var exception = CreateException("EXC-META-001", metadata: metadata);
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "actor");
|
||||
var fetched = await _repository.GetByIdAsync("EXC-META-001");
|
||||
|
||||
// Assert
|
||||
fetched!.Metadata.Should().HaveCount(3);
|
||||
fetched.Metadata["team"].Should().Be("security");
|
||||
fetched.Metadata["priority"].Should().Be("high");
|
||||
fetched.Metadata["ticket"].Should().Be("SEC-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEvidenceRefs_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var evidenceRefs = ImmutableArray.Create(
|
||||
"sha256:evidence1",
|
||||
"sha256:evidence2",
|
||||
"https://evidence.example.com/doc1");
|
||||
|
||||
var exception = CreateException("EXC-EVIDENCE-001", evidenceRefs: evidenceRefs);
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "actor");
|
||||
var fetched = await _repository.GetByIdAsync("EXC-EVIDENCE-001");
|
||||
|
||||
// Assert
|
||||
fetched!.EvidenceRefs.Should().HaveCount(3);
|
||||
fetched.EvidenceRefs.Should().Contain("sha256:evidence1");
|
||||
fetched.EvidenceRefs.Should().Contain("https://evidence.example.com/doc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithCompensatingControls_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var controls = ImmutableArray.Create(
|
||||
"WAF blocking malicious patterns",
|
||||
"Network segmentation prevents lateral movement");
|
||||
|
||||
var exception = CreateException("EXC-CONTROLS-001", compensatingControls: controls);
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "actor");
|
||||
var fetched = await _repository.GetByIdAsync("EXC-CONTROLS-001");
|
||||
|
||||
// Assert
|
||||
fetched!.CompensatingControls.Should().HaveCount(2);
|
||||
fetched.CompensatingControls.Should().Contain("WAF blocking malicious patterns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithEnvironments_ShouldPersistCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var environments = ImmutableArray.Create("dev", "staging");
|
||||
var exception = CreateException("EXC-ENV-001", environments: environments);
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "actor");
|
||||
var fetched = await _repository.GetByIdAsync("EXC-ENV-001");
|
||||
|
||||
// Assert
|
||||
fetched!.Scope.Environments.Should().HaveCount(2);
|
||||
fetched.Scope.Environments.Should().Contain(["dev", "staging"]);
|
||||
}
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string exceptionId,
|
||||
ExceptionStatus status = ExceptionStatus.Proposed,
|
||||
ExceptionType type = ExceptionType.Vulnerability,
|
||||
string vulnerabilityId = "CVE-2024-12345",
|
||||
DateTimeOffset? expiresAt = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
ImmutableArray<string>? evidenceRefs = null,
|
||||
ImmutableArray<string>? compensatingControls = null,
|
||||
ImmutableArray<string>? environments = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = type,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Environments = environments ?? []
|
||||
},
|
||||
OwnerId = "owner@example.com",
|
||||
RequesterId = "requester@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
|
||||
EvidenceRefs = evidenceRefs ?? [],
|
||||
CompensatingControls = compensatingControls ?? [],
|
||||
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresExceptionObjectRepository.
|
||||
/// Tests the new auditable exception objects with event sourcing.
|
||||
/// </summary>
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PostgresExceptionObjectRepository _repository;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
|
||||
public PostgresExceptionObjectRepositoryTests(PolicyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
||||
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region Create Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidException_PersistsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
var created = await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Assert
|
||||
created.Should().NotBeNull();
|
||||
created.ExceptionId.Should().Be(exception.ExceptionId);
|
||||
created.Version.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecordsCreatedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(1);
|
||||
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
|
||||
history.Events[0].ActorId.Should().Be("creator@example.com");
|
||||
history.Events[0].NewStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithClientInfo_IncludesInEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "creator@example.com", "192.168.1.1");
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events[0].ClientInfo.Should().Be("192.168.1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithWrongVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345") with { Version = 2 };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _repository.CreateAsync(exception, "creator@example.com"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetById Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithExistingException_ReturnsException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ExceptionId.Should().Be(exception.ExceptionId);
|
||||
fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345");
|
||||
fetched.Status.Should().Be(ExceptionStatus.Proposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithNonExistingException_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithValidVersion_UpdatesException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ApprovedAt = DateTimeOffset.UtcNow,
|
||||
ApproverIds = ["approver@example.com"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _repository.UpdateAsync(
|
||||
updated, ExceptionEventType.Approved, "approver@example.com", "Approved by security team");
|
||||
|
||||
// Assert
|
||||
result.Version.Should().Be(2);
|
||||
result.Status.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RecordsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.UpdateAsync(
|
||||
updated, ExceptionEventType.Approved, "approver@example.com", "Approved");
|
||||
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(2);
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
history.Events[1].PreviousStatus.Should().Be(ExceptionStatus.Proposed);
|
||||
history.Events[1].NewStatus.Should().Be(ExceptionStatus.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithWrongVersion_ThrowsConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
|
||||
// Try to update with wrong version
|
||||
var updated = exception with
|
||||
{
|
||||
Version = 99, // Wrong version
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConcurrencyException>(
|
||||
() => _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Query Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var proposed = CreateVulnerabilityException("CVE-2024-001");
|
||||
var active = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-ACTIVE",
|
||||
Status = ExceptionStatus.Active
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(proposed, "creator");
|
||||
await _repository.CreateAsync(active, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByFilterAsync(
|
||||
new ExceptionFilter { Status = ExceptionStatus.Proposed });
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be(proposed.ExceptionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_FiltersByVulnerabilityId()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-001"), "creator");
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-002"
|
||||
}, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetByFilterAsync(
|
||||
new ExceptionFilter { VulnerabilityId = "CVE-2024-001" });
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Scope.VulnerabilityId.Should().Be("CVE-2024-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFilterAsync_SupportsPagination()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.CreateAsync(CreateVulnerabilityException($"CVE-2024-{i:000}") with
|
||||
{
|
||||
ExceptionId = $"EXC-{i:000}"
|
||||
}, "creator");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 0 });
|
||||
var page2 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 2 });
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(2);
|
||||
page2.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_FindsMatchingActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345") with
|
||||
{
|
||||
Status = ExceptionStatus.Active
|
||||
};
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Act
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveByScopeAsync_ExcludesExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var expiredException = CreateVulnerabilityException("CVE-2024-12345") with
|
||||
{
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) // Already expired
|
||||
};
|
||||
await _repository.CreateAsync(expiredException, "creator");
|
||||
|
||||
// Act
|
||||
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
|
||||
var results = await _repository.GetActiveByScopeAsync(scope);
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiringAsync_FindsExceptionsWithinHorizon()
|
||||
{
|
||||
// Arrange
|
||||
var expiringSoon = CreateVulnerabilityException("CVE-2024-001") with
|
||||
{
|
||||
ExceptionId = "EXC-EXPIRING",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Within 7-day horizon
|
||||
};
|
||||
var expiresLater = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-LATER",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) // Outside horizon
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(expiringSoon, "creator");
|
||||
await _repository.CreateAsync(expiresLater, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7));
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpiredActiveAsync_FindsExpiredActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var expiredActive = CreateVulnerabilityException("CVE-2024-001") with
|
||||
{
|
||||
ExceptionId = "EXC-EXPIRED-ACTIVE",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
var validActive = CreateVulnerabilityException("CVE-2024-002") with
|
||||
{
|
||||
ExceptionId = "EXC-VALID-ACTIVE",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(expiredActive, "creator");
|
||||
await _repository.CreateAsync(validActive, "creator");
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetExpiredActiveAsync();
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ExceptionId.Should().Be("EXC-EXPIRED-ACTIVE");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History and Counts Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ReturnsChronologicalEvents()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Approve
|
||||
var approved = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(approved, ExceptionEventType.Approved, "approver");
|
||||
|
||||
// Activate
|
||||
var activated = approved with
|
||||
{
|
||||
Version = 3,
|
||||
Status = ExceptionStatus.Active,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "system");
|
||||
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
history.Events.Should().HaveCount(3);
|
||||
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
|
||||
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
|
||||
history.Events[2].EventType.Should().Be(ExceptionEventType.Activated);
|
||||
history.Events[0].SequenceNumber.Should().Be(1);
|
||||
history.Events[1].SequenceNumber.Should().Be(2);
|
||||
history.Events[2].SequenceNumber.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ForNonExistent_ReturnsEmptyHistory()
|
||||
{
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync("EXC-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
history.ExceptionId.Should().Be("EXC-NONEXISTENT");
|
||||
history.Events.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCountsAsync_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-001") with
|
||||
{
|
||||
ExceptionId = "EXC-1",
|
||||
Status = ExceptionStatus.Proposed
|
||||
}, "creator");
|
||||
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-002") with
|
||||
{
|
||||
ExceptionId = "EXC-2",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
}, "creator");
|
||||
|
||||
await _repository.CreateAsync(CreateVulnerabilityException("CVE-003") with
|
||||
{
|
||||
ExceptionId = "EXC-3",
|
||||
Status = ExceptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Expiring soon
|
||||
}, "creator");
|
||||
|
||||
// Act
|
||||
var counts = await _repository.GetCountsAsync();
|
||||
|
||||
// Assert
|
||||
counts.Total.Should().Be(3);
|
||||
counts.Proposed.Should().Be(1);
|
||||
counts.Active.Should().Be(2);
|
||||
counts.ExpiringSoon.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Update Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentUpdates_FailsWithConcurrencyException()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345");
|
||||
await _repository.CreateAsync(exception, "creator");
|
||||
|
||||
// Simulate concurrent updates by updating twice with same version
|
||||
var update1 = exception with
|
||||
{
|
||||
Version = 2,
|
||||
Status = ExceptionStatus.Approved,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// First update succeeds
|
||||
await _repository.UpdateAsync(update1, ExceptionEventType.Approved, "approver1");
|
||||
|
||||
// Second update with same expected version should fail
|
||||
var update2 = exception with
|
||||
{
|
||||
Version = 2, // Still expecting version 1
|
||||
Status = ExceptionStatus.Revoked,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConcurrencyException>(
|
||||
() => _repository.UpdateAsync(update2, ExceptionEventType.Revoked, "approver2"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private ExceptionObject CreateVulnerabilityException(string vulnerabilityId) => new()
|
||||
{
|
||||
ExceptionId = $"EXC-{Guid.NewGuid():N}"[..20],
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Proposed,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
TenantId = _tenantId
|
||||
},
|
||||
OwnerId = "security-team",
|
||||
RequesterId = "developer@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(90),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "This vulnerability is accepted due to compensating controls in place that mitigate the risk."
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user