// ----------------------------------------------------------------------------- // GatingReasonServiceTests.cs // Sprint: SPRINT_9200_0001_0001_SCANNER_gated_triage_contracts // Tasks: GTR-9200-019, GTR-9200-020, GTR-9200-021 // Description: Unit tests for gating reason logic, bucket counting, and VEX trust. // Tests the gating contract DTOs and their expected behavior. // ----------------------------------------------------------------------------- using FluentAssertions; using StellaOps.Scanner.Triage.Entities; using StellaOps.Scanner.WebService.Contracts; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; /// /// Unit tests for gating contracts and gating reason logic. /// Covers GTR-9200-019 (all gating reason paths), GTR-9200-020 (bucket counting), /// and GTR-9200-021 (VEX trust threshold comparison). /// public sealed class GatingReasonServiceTests { #region GTR-9200-019: Gating Reason Path Tests - Entity Model Validation [Trait("Category", TestCategories.Unit)] [Theory] [InlineData(GatingReason.None, false)] [InlineData(GatingReason.Unreachable, true)] [InlineData(GatingReason.PolicyDismissed, true)] [InlineData(GatingReason.Backported, true)] [InlineData(GatingReason.VexNotAffected, true)] [InlineData(GatingReason.Superseded, true)] [InlineData(GatingReason.UserMuted, true)] public void FindingGatingStatusDto_IsHiddenByDefault_MatchesGatingReason( GatingReason reason, bool expectedHidden) { // Arrange & Act var dto = new FindingGatingStatusDto { GatingReason = reason, IsHiddenByDefault = reason != GatingReason.None }; // Assert dto.IsHiddenByDefault.Should().Be(expectedHidden); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_UserMuted_HasExpectedExplanation() { // Arrange var dto = new FindingGatingStatusDto { GatingReason = GatingReason.UserMuted, IsHiddenByDefault = true, GatingExplanation = "This finding has been muted by a user decision.", WouldShowIf = new[] { "Un-mute the finding in triage settings" } }; // Assert dto.GatingExplanation.Should().Contain("muted"); dto.WouldShowIf.Should().ContainSingle(); dto.WouldShowIf.Should().Contain("Un-mute the finding in triage settings"); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_PolicyDismissed_HasPolicyIdInExplanation() { // Arrange var policyId = "security-policy-v1"; var dto = new FindingGatingStatusDto { GatingReason = GatingReason.PolicyDismissed, IsHiddenByDefault = true, GatingExplanation = $"Policy '{policyId}' dismissed this finding: Low risk tolerance", WouldShowIf = new[] { "Update policy to remove dismissal rule", "Remove policy exception" } }; // Assert dto.GatingExplanation.Should().Contain(policyId); dto.WouldShowIf.Should().HaveCount(2); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_VexNotAffected_IncludesTrustInfo() { // Arrange var dto = new FindingGatingStatusDto { GatingReason = GatingReason.VexNotAffected, IsHiddenByDefault = true, GatingExplanation = "VEX statement from 'redhat' declares not_affected (trust: 95%)", WouldShowIf = new[] { "Contest the VEX statement", "Lower trust threshold in policy" } }; // Assert dto.GatingExplanation.Should().Contain("redhat"); dto.GatingExplanation.Should().Contain("trust"); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_Backported_IncludesFixedVersion() { // Arrange var fixedVersion = "1.2.3-ubuntu1"; var dto = new FindingGatingStatusDto { GatingReason = GatingReason.Backported, IsHiddenByDefault = true, GatingExplanation = $"Vulnerability is fixed via distro backport in version {fixedVersion}.", WouldShowIf = new[] { "Override backport detection", "Report false positive in backport fix" } }; // Assert dto.GatingExplanation.Should().Contain(fixedVersion); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_Superseded_IncludesSupersedingCve() { // Arrange var supersedingCve = "CVE-2024-9999"; var dto = new FindingGatingStatusDto { GatingReason = GatingReason.Superseded, IsHiddenByDefault = true, GatingExplanation = $"This CVE has been superseded by {supersedingCve}.", WouldShowIf = new[] { "Show superseded CVEs in settings" } }; // Assert dto.GatingExplanation.Should().Contain(supersedingCve); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_Unreachable_HasSubgraphId() { // Arrange var subgraphId = "sha256:subgraph123"; var dto = new FindingGatingStatusDto { GatingReason = GatingReason.Unreachable, IsHiddenByDefault = true, SubgraphId = subgraphId, GatingExplanation = "Vulnerable code is not reachable from any application entrypoint.", WouldShowIf = new[] { "Add new entrypoint trace", "Enable 'show unreachable' filter" } }; // Assert dto.SubgraphId.Should().Be(subgraphId); dto.GatingExplanation.Should().Contain("not reachable"); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingGatingStatusDto_None_IsNotHidden() { // Arrange var dto = new FindingGatingStatusDto { GatingReason = GatingReason.None, IsHiddenByDefault = false }; // Assert dto.IsHiddenByDefault.Should().BeFalse(); dto.GatingExplanation.Should().BeNull(); dto.WouldShowIf.Should().BeNull(); } #endregion #region GTR-9200-020: Bucket Counting Logic Tests [Trait("Category", TestCategories.Unit)] [Fact] public void GatedBucketsSummaryDto_Empty_ReturnsZeroCounts() { // Arrange & Act var dto = GatedBucketsSummaryDto.Empty; // Assert dto.UnreachableCount.Should().Be(0); dto.PolicyDismissedCount.Should().Be(0); dto.BackportedCount.Should().Be(0); dto.VexNotAffectedCount.Should().Be(0); dto.SupersededCount.Should().Be(0); dto.UserMutedCount.Should().Be(0); dto.TotalHiddenCount.Should().Be(0); } [Trait("Category", TestCategories.Unit)] [Fact] public void GatedBucketsSummaryDto_TotalHiddenCount_SumsAllBuckets() { // Arrange var dto = new GatedBucketsSummaryDto { UnreachableCount = 10, PolicyDismissedCount = 5, BackportedCount = 3, VexNotAffectedCount = 7, SupersededCount = 2, UserMutedCount = 1 }; // Assert dto.TotalHiddenCount.Should().Be(28); } [Trait("Category", TestCategories.Unit)] [Fact] public void GatedBucketsSummaryDto_WithMixedCounts_CalculatesCorrectly() { // Arrange var dto = new GatedBucketsSummaryDto { UnreachableCount = 15, PolicyDismissedCount = 3, BackportedCount = 7, VexNotAffectedCount = 12, SupersededCount = 2, UserMutedCount = 5 }; // Assert dto.TotalHiddenCount.Should().Be(44); dto.UnreachableCount.Should().Be(15); dto.VexNotAffectedCount.Should().Be(12); } [Trait("Category", TestCategories.Unit)] [Fact] public void BulkTriageQueryWithGatingResponseDto_IncludesGatedBuckets() { // Arrange var dto = new BulkTriageQueryWithGatingResponseDto { TotalCount = 100, VisibleCount = 72, GatedBuckets = new GatedBucketsSummaryDto { UnreachableCount = 15, PolicyDismissedCount = 5, BackportedCount = 3, VexNotAffectedCount = 5 }, Findings = Array.Empty() }; // Assert dto.TotalCount.Should().Be(100); dto.VisibleCount.Should().Be(72); dto.GatedBuckets.Should().NotBeNull(); dto.GatedBuckets!.TotalHiddenCount.Should().Be(28); } [Trait("Category", TestCategories.Unit)] [Fact] public void BulkTriageQueryWithGatingRequestDto_SupportsGatingReasonFilter() { // Arrange var dto = new BulkTriageQueryWithGatingRequestDto { Query = new BulkTriageQueryRequestDto(), IncludeHidden = true, GatingReasonFilter = new[] { GatingReason.Unreachable, GatingReason.VexNotAffected } }; // Assert dto.IncludeHidden.Should().BeTrue(); dto.GatingReasonFilter.Should().HaveCount(2); dto.GatingReasonFilter.Should().Contain(GatingReason.Unreachable); dto.GatingReasonFilter.Should().Contain(GatingReason.VexNotAffected); } [Trait("Category", TestCategories.Unit)] [Fact] public void BulkTriageQueryWithGatingRequestDto_DefaultsToNotIncludeHidden() { // Arrange var dto = new BulkTriageQueryWithGatingRequestDto { Query = new BulkTriageQueryRequestDto() }; // Assert dto.IncludeHidden.Should().BeFalse(); dto.GatingReasonFilter.Should().BeNull(); } #endregion #region GTR-9200-021: VEX Trust Threshold Comparison Tests [Trait("Category", TestCategories.Unit)] [Fact] public void VexTrustBreakdownDto_AllComponents_SumToCompositeScore() { // Arrange - weights: issuer=0.4, recency=0.2, justification=0.2, evidence=0.2 var dto = new VexTrustBreakdownDto { IssuerTrust = 1.0, // Max issuer trust (NVD) RecencyTrust = 1.0, // Very recent JustificationTrust = 1.0, // Detailed justification EvidenceTrust = 1.0 // Signed with ledger }; // Assert - all max values = composite score of 1.0 var compositeScore = (dto.IssuerTrust * 0.4) + (dto.RecencyTrust * 0.2) + (dto.JustificationTrust * 0.2) + (dto.EvidenceTrust * 0.2); compositeScore.Should().Be(1.0); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexTrustBreakdownDto_LowIssuerTrust_ReducesCompositeScore() { // Arrange - unknown issuer has low trust (0.5) var dto = new VexTrustBreakdownDto { IssuerTrust = 0.5, // Unknown issuer RecencyTrust = 1.0, JustificationTrust = 1.0, EvidenceTrust = 1.0 }; // Assert var compositeScore = (dto.IssuerTrust * 0.4) + (dto.RecencyTrust * 0.2) + (dto.JustificationTrust * 0.2) + (dto.EvidenceTrust * 0.2); compositeScore.Should().Be(0.8); } [Trait("Category", TestCategories.Unit)] [Fact] public void TriageVexTrustStatusDto_MeetsPolicyThreshold_WhenTrustExceedsThreshold() { // Arrange var dto = new TriageVexTrustStatusDto { VexStatus = new TriageVexStatusDto { Status = "not_affected" }, TrustScore = 0.85, PolicyTrustThreshold = 0.7, MeetsPolicyThreshold = true }; // Assert dto.TrustScore.Should().NotBeNull(); dto.PolicyTrustThreshold.Should().NotBeNull(); dto.TrustScore!.Value.Should().BeGreaterThan(dto.PolicyTrustThreshold!.Value); dto.MeetsPolicyThreshold.Should().BeTrue(); } [Trait("Category", TestCategories.Unit)] [Fact] public void TriageVexTrustStatusDto_DoesNotMeetThreshold_WhenTrustBelowThreshold() { // Arrange var dto = new TriageVexTrustStatusDto { VexStatus = new TriageVexStatusDto { Status = "not_affected" }, TrustScore = 0.5, PolicyTrustThreshold = 0.7, MeetsPolicyThreshold = false }; // Assert dto.TrustScore.Should().NotBeNull(); dto.PolicyTrustThreshold.Should().NotBeNull(); dto.TrustScore!.Value.Should().BeLessThan(dto.PolicyTrustThreshold!.Value); dto.MeetsPolicyThreshold.Should().BeFalse(); } [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("nvd", 1.0)] [InlineData("redhat", 0.95)] [InlineData("canonical", 0.95)] [InlineData("debian", 0.95)] [InlineData("suse", 0.9)] [InlineData("microsoft", 0.9)] public void VexIssuerTrust_KnownIssuers_HaveExpectedTrustScores(string _, double expectedTrust) { // This test documents the expected trust scores for known issuers // The actual implementation is in GatingReasonService.GetIssuerTrust() expectedTrust.Should().BeGreaterThanOrEqualTo(0.9); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexRecencyTrust_RecentStatement_HasHighTrust() { // Arrange - VEX from within a week var validFrom = DateTimeOffset.UtcNow.AddDays(-3); var age = DateTimeOffset.UtcNow - validFrom; // Assert - within a week = trust 1.0 age.TotalDays.Should().BeLessThan(7); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexRecencyTrust_OldStatement_HasLowTrust() { // Arrange - VEX from over a year ago var validFrom = DateTimeOffset.UtcNow.AddYears(-2); var age = DateTimeOffset.UtcNow - validFrom; // Assert - over a year = trust 0.3 age.TotalDays.Should().BeGreaterThan(365); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexJustificationTrust_DetailedJustification_HasHighTrust() { // Arrange - 500+ chars = trust 1.0 var justification = new string('x', 600); // Assert justification.Length.Should().BeGreaterThanOrEqualTo(500); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexJustificationTrust_ShortJustification_HasLowTrust() { // Arrange - < 50 chars = trust 0.4 var justification = "short"; // Assert justification.Length.Should().BeLessThan(50); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexEvidenceTrust_SignedWithLedger_HasHighTrust() { // Arrange - DSSE envelope + signature ref + source ref var now = DateTimeOffset.UtcNow; var vex = new TriageEffectiveVex { Id = Guid.NewGuid(), Status = TriageVexStatus.NotAffected, DsseEnvelopeHash = "sha256:signed", SignatureRef = "ledger-entry", SourceDomain = "nvd", SourceRef = "NVD-CVE-2024-1234", ValidFrom = now, CollectedAt = now }; // Assert - all evidence factors present vex.DsseEnvelopeHash.Should().NotBeNull(); vex.SignatureRef.Should().NotBeNull(); vex.SourceRef.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public void VexEvidenceTrust_NoEvidence_HasBaseTrust() { // Arrange - no signature, no ledger, no source var now = DateTimeOffset.UtcNow; var vex = new TriageEffectiveVex { Id = Guid.NewGuid(), Status = TriageVexStatus.NotAffected, DsseEnvelopeHash = null, SignatureRef = null, SourceDomain = "unknown", SourceRef = "unknown", ValidFrom = now, CollectedAt = now }; // Assert - base trust only vex.DsseEnvelopeHash.Should().BeNull(); vex.SignatureRef.Should().BeNull(); } #endregion #region Edge Cases and Entity Model Validation [Trait("Category", TestCategories.Unit)] [Fact] public void TriageFinding_RequiredFields_AreSet() { // Arrange var now = DateTimeOffset.UtcNow; var finding = new TriageFinding { Id = Guid.NewGuid(), AssetLabel = "test-asset", Purl = "pkg:npm/test@1.0.0", CveId = "CVE-2024-1234", FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; // Assert finding.AssetLabel.Should().NotBeNullOrEmpty(); finding.Purl.Should().NotBeNullOrEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void TriagePolicyDecision_PolicyActions_AreValid() { // Valid actions: dismiss, waive, tolerate, block var validActions = new[] { "dismiss", "waive", "tolerate", "block" }; foreach (var action in validActions) { var decision = new TriagePolicyDecision { Id = Guid.NewGuid(), PolicyId = "test-policy", Action = action, AppliedAt = DateTimeOffset.UtcNow }; decision.Action.Should().Be(action); } } [Trait("Category", TestCategories.Unit)] [Fact] public void TriageEffectiveVex_VexStatuses_AreAllDefined() { // Arrange var statuses = Enum.GetValues(); // Assert - all expected statuses exist statuses.Should().Contain(TriageVexStatus.NotAffected); statuses.Should().Contain(TriageVexStatus.Affected); statuses.Should().Contain(TriageVexStatus.UnderInvestigation); } [Trait("Category", TestCategories.Unit)] [Fact] public void TriageReachability_Values_AreAllDefined() { // Arrange var values = Enum.GetValues(); // Assert values.Should().Contain(TriageReachability.Yes); values.Should().Contain(TriageReachability.No); values.Should().Contain(TriageReachability.Unknown); } [Trait("Category", TestCategories.Unit)] [Fact] public void TriageReachabilityResult_RequiredInputsHash_IsSet() { // Arrange var result = new TriageReachabilityResult { Id = Guid.NewGuid(), Reachable = TriageReachability.No, InputsHash = "sha256:inputs-hash", SubgraphId = "sha256:subgraph", ComputedAt = DateTimeOffset.UtcNow }; // Assert result.InputsHash.Should().NotBeNullOrEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void GatingReason_AllValues_HaveCorrectNumericMapping() { // Document the enum values for API stability GatingReason.None.Should().Be((GatingReason)0); GatingReason.Unreachable.Should().Be((GatingReason)1); GatingReason.PolicyDismissed.Should().Be((GatingReason)2); GatingReason.Backported.Should().Be((GatingReason)3); GatingReason.VexNotAffected.Should().Be((GatingReason)4); GatingReason.Superseded.Should().Be((GatingReason)5); GatingReason.UserMuted.Should().Be((GatingReason)6); } [Trait("Category", TestCategories.Unit)] [Fact] public void FindingTriageStatusWithGatingDto_CombinesBaseStatusWithGating() { // Arrange var baseStatus = new FindingTriageStatusDto { FindingId = Guid.NewGuid().ToString(), Lane = "high", Verdict = "Block" }; var gating = new FindingGatingStatusDto { GatingReason = GatingReason.Unreachable, IsHiddenByDefault = true }; var dto = new FindingTriageStatusWithGatingDto { BaseStatus = baseStatus, Gating = gating }; // Assert dto.BaseStatus.Should().NotBeNull(); dto.Gating.Should().NotBeNull(); dto.Gating!.GatingReason.Should().Be(GatingReason.Unreachable); } #endregion }