old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CvssThresholdGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
|
||||
// Tasks: CVSS-GATE-008, CVSS-GATE-009
|
||||
// Description: Unit tests for CVSS threshold gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CvssThresholdGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment = "production",
|
||||
string? cveId = null,
|
||||
Dictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Environment = environment,
|
||||
CveId = cveId,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { Enabled = false };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CveOnDenylist_ReturnsFail()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("denylist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CveOnAllowlist_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-99999" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-99999"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("allowlist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DenylistTakesPrecedenceOverAllowlist()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" },
|
||||
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("denylist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCvssScore_FailOnMissingFalse_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { FailOnMissingCvss = false };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("no_cvss_available", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCvssScore_FailOnMissingTrue_ReturnsFail()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { FailOnMissingCvss = true };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("missing_cvss", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(6.9, true)] // Below threshold
|
||||
[InlineData(7.0, false)] // At threshold (fails - must be strictly below)
|
||||
[InlineData(7.1, false)] // Above threshold
|
||||
[InlineData(9.9, false)] // Well above threshold
|
||||
public async Task EvaluateAsync_V31Score_DefaultThreshold_ReturnsExpected(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7.9, true)] // Below staging threshold
|
||||
[InlineData(8.0, false)] // At staging threshold
|
||||
[InlineData(8.5, false)] // Above staging threshold
|
||||
public async Task EvaluateAsync_StagingEnvironment_UsesStagingThreshold(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0,
|
||||
["staging"] = 8.0,
|
||||
["development"] = 9.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8.9, true)] // Below development threshold
|
||||
[InlineData(9.0, false)] // At development threshold
|
||||
[InlineData(9.5, false)] // Above development threshold
|
||||
public async Task EvaluateAsync_DevelopmentEnvironment_UsesDevelopmentThreshold(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0,
|
||||
["staging"] = 8.0,
|
||||
["development"] = 9.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultThreshold()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 5.0,
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = 5.5 };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("cvss_exceeds_threshold", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_V40Score_UsesV40WhenPreferred()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v4.0"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 8.0, // Would fail
|
||||
CvssV40BaseScore = 6.0 // Would pass
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("v4.0", result.Details["cvss_version"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_HighestPreference_UsesHigherScore()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.5,
|
||||
CvssVersionPreference = "highest"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0, // Would pass alone
|
||||
CvssV40BaseScore = 8.0 // Would fail, and is higher
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal(8.0, (double)result.Details["cvss_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireAllVersionsPass_BothMustPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.5,
|
||||
CvssVersionPreference = "highest",
|
||||
RequireAllVersionsPass = true
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0, // Would pass
|
||||
CvssV40BaseScore = 8.0 // Would fail
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireAllVersionsPass_BothPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 8.5,
|
||||
CvssVersionPreference = "highest",
|
||||
RequireAllVersionsPass = true
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0,
|
||||
CvssV40BaseScore = 8.0
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MetadataFallback_ExtractsFromContext()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["cvss_v31_score"] = "6.5"
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001", metadata: metadata));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal(6.5, (double)result.Details["cvss_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CaseInsensitiveCveMatch()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cve-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("allowlist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_IncludesAllDetailsInResult()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 8.5,
|
||||
CvssV40BaseScore = 7.2
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "production", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal(7.0, (double)result.Details["threshold"]);
|
||||
Assert.Equal("production", result.Details["environment"]);
|
||||
Assert.Equal("v3.1", result.Details["cvss_version"]);
|
||||
Assert.Equal(8.5, (double)result.Details["cvss_score"]);
|
||||
Assert.Equal(8.5, (double)result.Details["cvss_v31_score"]);
|
||||
Assert.Equal(7.2, (double)result.Details["cvss_v40_score"]);
|
||||
Assert.Equal("CVE-2024-00001", result.Details["cve_id"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomPresenceGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
|
||||
// Tasks: SBOM-GATE-009
|
||||
// Description: Unit tests for SBOM presence gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomPresenceGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment = "production",
|
||||
Dictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Environment = environment,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { Enabled = false };
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OptionalEnforcement_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["development"] = SbomEnforcementLevel.Optional
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("optional_enforcement", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSbom_RequiredEnforcement_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("sbom_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSbom_RecommendedEnforcement_ReturnsPassWithWarning()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["staging"] = SbomEnforcementLevel.Recommended
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("sbom_missing_recommended", result.Reason);
|
||||
Assert.Contains("warning", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ValidSbom_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 10,
|
||||
HasPrimaryComponent = true,
|
||||
SchemaValid = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("sbom_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("spdx-2.2")]
|
||||
[InlineData("spdx-2.3")]
|
||||
[InlineData("spdx-3.0.1")]
|
||||
[InlineData("cyclonedx-1.4")]
|
||||
[InlineData("cyclonedx-1.5")]
|
||||
[InlineData("cyclonedx-1.6")]
|
||||
[InlineData("cyclonedx-1.7")]
|
||||
public async Task EvaluateAsync_AcceptedFormats_ReturnsPass(string format)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = format,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unknown-1.0")]
|
||||
[InlineData("custom-format")]
|
||||
[InlineData("spdx-1.0")]
|
||||
public async Task EvaluateAsync_InvalidFormat_ReturnsFail(string format)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = format,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("invalid_format", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InsufficientComponents_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { MinimumComponents = 5 };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 3,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("insufficient_components", result.Reason);
|
||||
Assert.Equal(5, (int)result.Details["minimum_components"]);
|
||||
Assert.Equal(3, (int)result.Details["component_count"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SchemaValidationFailed_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { SchemaValidation = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
SchemaValid = false,
|
||||
SchemaErrors = new[] { "Missing required field 'name'", "Invalid date format" }
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("schema_validation_failed", result.Reason);
|
||||
Assert.Contains("schema_errors", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_MissingSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_InvalidSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = true,
|
||||
SignatureValid = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_invalid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_ValidSignature_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = true,
|
||||
SignatureValid = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PrimaryComponentRequired_Missing_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequirePrimaryComponent = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("primary_component_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("production", SbomEnforcementLevel.Required)]
|
||||
[InlineData("staging", SbomEnforcementLevel.Required)]
|
||||
[InlineData("development", SbomEnforcementLevel.Optional)]
|
||||
public async Task EvaluateAsync_EnvironmentEnforcement_UsesCorrectLevel(string environment, SbomEnforcementLevel expectedLevel)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = SbomEnforcementLevel.Required,
|
||||
["staging"] = SbomEnforcementLevel.Required,
|
||||
["development"] = SbomEnforcementLevel.Optional
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: environment));
|
||||
|
||||
Assert.Equal(expectedLevel.ToString(), result.Details["enforcement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultEnforcement()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
DefaultEnforcement = SbomEnforcementLevel.Recommended,
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = SbomEnforcementLevel.Required
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa"));
|
||||
|
||||
Assert.Equal(SbomEnforcementLevel.Recommended.ToString(), result.Details["enforcement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MetadataFallback_ParsesSbomInfo()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["sbom_present"] = "true",
|
||||
["sbom_format"] = "cyclonedx-1.6",
|
||||
["sbom_component_count"] = "25",
|
||||
["sbom_has_primary_component"] = "true"
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(metadata: metadata));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("cyclonedx-1.6", result.Details["format"]);
|
||||
Assert.Equal(25, (int)result.Details["component_count"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SPDX-2.3", "spdx-2.3")]
|
||||
[InlineData("CycloneDX-1.6", "cyclonedx-1.6")]
|
||||
[InlineData("spdx 2.3", "spdx-2.3")]
|
||||
[InlineData("cdx-1.5", "cyclonedx-1.5")]
|
||||
public async Task EvaluateAsync_FormatNormalization_HandlesVariations(string inputFormat, string normalizedExpected)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = inputFormat,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
// If format was accepted, it was normalized correctly
|
||||
Assert.True(result.Passed, $"Format '{inputFormat}' should normalize to '{normalizedExpected}' and be accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_IncludesOptionalMetadata()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var createdAt = new DateTimeOffset(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 10,
|
||||
HasPrimaryComponent = true,
|
||||
DocumentUri = "urn:sbom:example:12345",
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("urn:sbom:example:12345", result.Details["document_uri"]);
|
||||
Assert.Contains("2026-01-15", (string)result.Details["created_at"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignatureRequiredGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
|
||||
// Tasks: SIG-GATE-009
|
||||
// Description: Unit tests for signature required gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SignatureRequiredGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(string environment = "production") => new()
|
||||
{
|
||||
Environment = environment
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions { Enabled = false };
|
||||
var gate = new SignatureRequiredGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>(); // No signatures
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_validation_failed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllValidSignatures_ReturnsPass()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("signatures_verified", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InvalidSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = false, VerificationErrors = new[] { "Invalid hash" } },
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("failures", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NotRequiredType_PassesWithoutSignature()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = false },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = true },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = true }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
// No SBOM signature - but it's not required
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("build@company.com", new[] { "build@company.com" }, true)]
|
||||
[InlineData("release@company.com", new[] { "*@company.com" }, true)]
|
||||
[InlineData("external@other.com", new[] { "*@company.com" }, false)]
|
||||
[InlineData("build@company.com", new[] { "other@company.com" }, false)]
|
||||
public async Task EvaluateAsync_IssuerValidation_EnforcesConstraints(
|
||||
string signerIdentity,
|
||||
string[] trustedIssuers,
|
||||
bool expectedPass)
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(trustedIssuers, StringComparer.OrdinalIgnoreCase)
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = signerIdentity
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ES256", true)]
|
||||
[InlineData("RS256", true)]
|
||||
[InlineData("EdDSA", true)]
|
||||
[InlineData("UNKNOWN", false)]
|
||||
public async Task EvaluateAsync_AlgorithmValidation_EnforcesAccepted(string algorithm, bool expectedPass)
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
Algorithm = algorithm
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeyIdValidation_EnforcesConstraints()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedKeyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "key-001", "key-002" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
KeyId = "key-999",
|
||||
IsKeyless = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessSignature_ValidWithTransparencyLog()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = true,
|
||||
RequireTransparencyLogInclusion = true,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = true,
|
||||
CertificateChainValid = true
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessSignature_FailsWithoutTransparencyLog()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = true,
|
||||
RequireTransparencyLogInclusion = true,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessDisabled_FailsKeylessSignature()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = false,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentOverride_SkipsTypes()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = true },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = true }
|
||||
},
|
||||
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["development"] = new EnvironmentSignatureConfig
|
||||
{
|
||||
SkipEvidenceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "sbom", "vex" }
|
||||
}
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
// Only attestation signature in development
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentOverride_AddsIssuers()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "prod@company.com" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
},
|
||||
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["staging"] = new EnvironmentSignatureConfig
|
||||
{
|
||||
AdditionalIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "staging@company.com" }
|
||||
}
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = "staging@company.com"
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InvalidCertificateChain_Fails()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = true,
|
||||
CertificateChainValid = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WildcardIssuerMatch_MatchesSubdomains()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "*@*.company.com" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = "build@ci.company.com"
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
// Task: Unit tests for VexProofGate anchor-aware mode
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
public class VexProofGateTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static MergeResult CreateMergeResult(VexStatus status) =>
|
||||
new()
|
||||
{
|
||||
Status = status,
|
||||
Confidence = 0.9,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = status,
|
||||
OriginalScore = 0.9,
|
||||
AdjustedScore = 0.9,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "Test claim"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions { Enabled = false };
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext { Environment = "production" };
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_RequiresAnchoring()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "false" // Not anchored
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("vex_not_anchored", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_PassesWithAnchoring()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = false
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenRekorRequired_FailsWithoutRekor()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_rekor_verified"] = "false"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("rekor_verification_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenRekorRequired_PassesWithRekor()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123",
|
||||
["vex_proof_rekor_verified"] = "true",
|
||||
["vex_proof_rekor_log_index"] = "12345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
Assert.True(result.Details.ContainsKey("rekorLogIndex"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StrictAnchorAware_EnforcesAllRequirements()
|
||||
{
|
||||
// Arrange
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_all_signed"] = "true",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123",
|
||||
["vex_proof_rekor_verified"] = "true",
|
||||
["vex_proof_rekor_log_index"] = "12345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StrictAnchorAware_FailsWithoutSignedStatements()
|
||||
{
|
||||
// Arrange
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_all_signed"] = "false", // Not signed
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_rekor_verified"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("unsigned_statements", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrictAnchorAware_HasExpectedDefaults()
|
||||
{
|
||||
// Act
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.Enabled);
|
||||
Assert.Equal("high", options.MinimumConfidenceTier);
|
||||
Assert.True(options.RequireProofForNotAffected);
|
||||
Assert.True(options.RequireProofForFixed);
|
||||
Assert.True(options.RequireSignedStatements);
|
||||
Assert.True(options.AnchorAwareMode);
|
||||
Assert.True(options.RequireVexAnchoring);
|
||||
Assert.True(options.RequireRekorVerification);
|
||||
Assert.Equal(0, options.MaxAllowedConflicts);
|
||||
Assert.Equal(72, options.MaxProofAgeHours);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user