333 lines
9.6 KiB
C#
333 lines
9.6 KiB
C#
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_IsBlockedByRecheck_WhenBlockTriggered_ShouldBeTrue()
|
|
{
|
|
// Arrange
|
|
var exception = CreateException(recheckResult: new RecheckEvaluationResult
|
|
{
|
|
IsTriggered = true,
|
|
TriggeredConditions = [],
|
|
RecommendedAction = RecheckAction.Block,
|
|
EvaluatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
// Act & Assert
|
|
exception.IsBlockedByRecheck.Should().BeTrue();
|
|
exception.RequiresReapproval.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ExceptionObject_RequiresReapproval_WhenReapprovalTriggered_ShouldBeTrue()
|
|
{
|
|
// Arrange
|
|
var exception = CreateException(recheckResult: new RecheckEvaluationResult
|
|
{
|
|
IsTriggered = true,
|
|
TriggeredConditions = [],
|
|
RecommendedAction = RecheckAction.RequireReapproval,
|
|
EvaluatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
// Act & Assert
|
|
exception.RequiresReapproval.Should().BeTrue();
|
|
exception.IsBlockedByRecheck.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs()
|
|
{
|
|
// Arrange
|
|
var metadata = ImmutableDictionary<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,
|
|
RecheckEvaluationResult? recheckResult = null)
|
|
{
|
|
return new ExceptionObject
|
|
{
|
|
ExceptionId = $"EXC-{Guid.NewGuid():N}",
|
|
Version = 1,
|
|
Status = status,
|
|
Type = type,
|
|
Scope = new ExceptionScope
|
|
{
|
|
VulnerabilityId = "CVE-2024-12345"
|
|
},
|
|
OwnerId = "owner@example.com",
|
|
RequesterId = "requester@example.com",
|
|
ApproverIds = approverIds ?? [],
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
|
|
ReasonCode = reason,
|
|
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
|
|
EvidenceRefs = evidenceRefs ?? [],
|
|
CompensatingControls = [],
|
|
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty,
|
|
LastRecheckResult = recheckResult,
|
|
LastRecheckAt = recheckResult?.EvaluatedAt
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|