Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors

Sprints completed:
- SPRINT_20260110_012_* (golden set diff layer - 10 sprints)
- SPRINT_20260110_013_* (advisory chat - 4 sprints)

Build fixes applied:
- Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create
- Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite)
- Fix VexSchemaValidationTests FluentAssertions method name
- Fix FixChainGateIntegrationTests ambiguous type references
- Fix AdvisoryAI test files required properties and namespace aliases
- Add stub types for CveMappingController (ICveSymbolMappingService)
- Fix VerdictBuilderService static context issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
master
2026-01-11 10:09:07 +02:00
parent a3b2f30a11
commit 7f7eb8b228
232 changed files with 58979 additions and 91 deletions

View File

@@ -0,0 +1,464 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-004 - FixChain Gate Unit Tests
using System.Collections.Immutable;
using Moq;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
/// <summary>
/// Unit tests for <see cref="FixChainGate"/> evaluation scenarios.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FixChainGateTests
{
private readonly Mock<TimeProvider> _timeProviderMock;
private readonly DateTimeOffset _fixedTime = new(2026, 1, 11, 12, 0, 0, TimeSpan.Zero);
private readonly FixChainGateOptions _defaultOptions;
public FixChainGateTests()
{
_timeProviderMock = new Mock<TimeProvider>();
_timeProviderMock.Setup(t => t.GetUtcNow()).Returns(_fixedTime);
_defaultOptions = new FixChainGateOptions
{
Enabled = true,
RequiredSeverities = ["critical", "high"],
MinimumConfidence = 0.85m,
AllowInconclusive = false,
GracePeriodDays = 7
};
}
private FixChainGate CreateGate(FixChainGateOptions? options = null)
{
return new FixChainGate(options ?? _defaultOptions, _timeProviderMock.Object);
}
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
// Arrange
var options = new FixChainGateOptions { Enabled = false };
var gate = CreateGate(options);
var context = CreatePolicyContext("critical");
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_LowSeverity_ReturnsPass()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("low");
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("does not require", result.Reason);
}
[Fact]
public async Task EvaluateAsync_MediumSeverity_ReturnsPass()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("medium");
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_CriticalSeverity_NoAttestation_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("critical");
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("no FixChain attestation", result.Reason);
}
[Fact]
public async Task EvaluateAsync_HighSeverity_NoAttestation_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("high");
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
}
[Fact]
public async Task EvaluateAsync_CriticalSeverity_WithFixedAttestation_ReturnsPass()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "fixed",
["fixchain.confidence"] = "0.95"
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("verified", result.Reason);
}
[Fact]
public async Task EvaluateAsync_FixedVerdict_BelowMinConfidence_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "fixed",
["fixchain.confidence"] = "0.70" // Below 0.85 minimum
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("below minimum", result.Reason);
}
[Fact]
public async Task EvaluateAsync_PartialVerdict_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "partial",
["fixchain.confidence"] = "0.90"
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("Partial fix", result.Reason);
}
[Fact]
public async Task EvaluateAsync_NotFixedVerdict_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "not_fixed",
["fixchain.confidence"] = "0.95"
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("NOT fixed", result.Reason);
}
[Fact]
public async Task EvaluateAsync_InconclusiveVerdict_WhenNotAllowed_ReturnsFail()
{
// Arrange
var options = _defaultOptions with { AllowInconclusive = false };
var gate = CreateGate(options);
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "inconclusive",
["fixchain.confidence"] = "0.50"
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("inconclusive", result.Reason);
}
[Fact]
public async Task EvaluateAsync_InconclusiveVerdict_WhenAllowed_ReturnsPass()
{
// Arrange
var options = _defaultOptions with { AllowInconclusive = true };
var gate = CreateGate(options);
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "inconclusive",
["fixchain.confidence"] = "0.50"
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("allowed by policy", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WithinGracePeriod_ReturnsPass()
{
// Arrange
var gate = CreateGate();
var cvePublished = _fixedTime.AddDays(-3); // 3 days ago, within 7-day grace
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.cvePublishedAt"] = cvePublished.ToString("o")
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("grace period", result.Reason);
}
[Fact]
public async Task EvaluateAsync_AfterGracePeriod_NoAttestation_ReturnsFail()
{
// Arrange
var gate = CreateGate();
var cvePublished = _fixedTime.AddDays(-10); // 10 days ago, past 7-day grace
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.cvePublishedAt"] = cvePublished.ToString("o")
});
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
}
[Fact]
public void EvaluateDirect_FixedVerdict_ReturnsAllow()
{
// Arrange
var gate = CreateGate();
var fixChainContext = new FixChainGateContext
{
HasAttestation = true,
Verdict = "fixed",
Confidence = 0.95m,
VerifiedAt = _fixedTime,
AttestationDigest = "sha256:test"
};
// Act
var result = gate.EvaluateDirect(fixChainContext, "production", "critical");
// Assert
Assert.True(result.Passed);
Assert.Equal(FixChainGateResult.DecisionAllow, result.Decision);
}
[Fact]
public void EvaluateDirect_NoAttestation_ReturnsBlock()
{
// Arrange
var gate = CreateGate();
var fixChainContext = new FixChainGateContext
{
HasAttestation = false
};
// Act
var result = gate.EvaluateDirect(fixChainContext, "production", "critical");
// Assert
Assert.False(result.Passed);
Assert.Equal(FixChainGateResult.DecisionBlock, result.Decision);
}
[Fact]
public void EvaluateDirect_InconclusiveAllowed_ReturnsWarn()
{
// Arrange
var options = _defaultOptions with { AllowInconclusive = true };
var gate = CreateGate(options);
var fixChainContext = new FixChainGateContext
{
HasAttestation = true,
Verdict = "inconclusive",
Confidence = 0.50m
};
// Act
var result = gate.EvaluateDirect(fixChainContext, "production", "critical");
// Assert
Assert.True(result.Passed);
Assert.Equal(FixChainGateResult.DecisionAllow, result.Decision);
}
[Fact]
public async Task EvaluateAsync_ProductionEnvironment_UsesHigherConfidence()
{
// Arrange
var options = new FixChainGateOptions
{
Enabled = true,
RequiredSeverities = ["critical"],
MinimumConfidence = 0.70m,
EnvironmentConfidence = new Dictionary<string, decimal>
{
["production"] = 0.95m,
["staging"] = 0.80m
}
};
var gate = CreateGate(options);
// Context with 0.90 confidence - passes staging but not production
var context = CreatePolicyContext("critical", new Dictionary<string, string>
{
["fixchain.hasAttestation"] = "true",
["fixchain.verdict"] = "fixed",
["fixchain.confidence"] = "0.90"
});
context = context with { Environment = "production" };
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Contains("below minimum", result.Reason);
}
[Fact]
public async Task EvaluateAsync_DevelopmentEnvironment_NoRequirements()
{
// Arrange
var options = new FixChainGateOptions
{
Enabled = true,
RequiredSeverities = ["critical"],
EnvironmentSeverities = new Dictionary<string, IReadOnlyList<string>>
{
["production"] = ["critical", "high"],
["development"] = [] // No requirements
}
};
var gate = CreateGate(options);
var context = CreatePolicyContext("critical") with { Environment = "development" };
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("No severity requirements", result.Reason);
}
[Fact]
public void Options_DefaultValues_AreReasonable()
{
// Arrange & Act
var options = new FixChainGateOptions();
// Assert
Assert.True(options.Enabled);
Assert.Contains("critical", options.RequiredSeverities);
Assert.Contains("high", options.RequiredSeverities);
Assert.True(options.MinimumConfidence >= 0.80m);
Assert.False(options.AllowInconclusive);
Assert.True(options.GracePeriodDays > 0);
}
private static PolicyGateContext CreatePolicyContext(
string severity,
Dictionary<string, string>? metadata = null)
{
return new PolicyGateContext
{
Environment = "production",
Severity = severity,
CveId = "CVE-2024-1234",
SubjectKey = "pkg:generic/test@1.0.0",
Metadata = metadata
};
}
private static MergeResult CreateMergeResult()
{
var emptyClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 1.0,
AdjustedScore = 1.0,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
};
return new MergeResult
{
Status = VexStatus.Affected,
Confidence = 0.9,
HasConflicts = false,
AllClaims = [emptyClaim],
WinningClaim = emptyClaim,
Conflicts = []
};
}
}

View File

@@ -0,0 +1,354 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-009 - Integration Tests
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.BinaryIndex.GoldenSet;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.Predicates.FixChain;
using FixChainContext = StellaOps.Policy.Predicates.FixChain.FixChainGateContext;
using FixChainOpts = StellaOps.Policy.Predicates.FixChain.FixChainGateOptions;
using StellaOps.Policy.TrustLattice;
using StellaOps.RiskEngine.Core.Providers.FixChain;
using Xunit;
namespace StellaOps.Policy.Tests.Integration;
[Trait("Category", "Integration")]
public sealed class FixChainGateIntegrationTests
{
private readonly Mock<IFixChainAttestationClient> _attestationClientMock;
private readonly Mock<IGoldenSetStore> _goldenSetStoreMock;
private readonly ServiceProvider _serviceProvider;
public FixChainGateIntegrationTests()
{
_attestationClientMock = new Mock<IFixChainAttestationClient>();
_goldenSetStoreMock = new Mock<IGoldenSetStore>();
var services = new ServiceCollection();
// Register mocks
services.AddSingleton(_attestationClientMock.Object);
services.AddSingleton(_goldenSetStoreMock.Object);
// Register FixChain gate services
services.Configure<FixChainOpts>(_ => { }); // Default options
services.AddFixChainGate();
_serviceProvider = services.BuildServiceProvider();
}
[Fact]
public async Task FullPolicyEvaluation_WithFixChainGate_Works()
{
// Arrange
var predicate = _serviceProvider.GetRequiredService<IFixChainGatePredicate>();
var context = new FixChainContext
{
CveId = "CVE-2024-12345",
ComponentPurl = "pkg:npm/lodash@4.17.21",
Severity = "critical",
CvssScore = 9.8m,
BinarySha256 = new string('a', 64)
};
var attestation = new FixChainAttestationData
{
ContentDigest = "sha256:abc123",
CveId = "CVE-2024-12345",
ComponentPurl = "pkg:npm/lodash@4.17.21",
BinarySha256 = new string('a', 64),
Verdict = new FixChainVerdictData
{
Status = "fixed",
Confidence = 0.95m,
Rationale = ["All vulnerable paths eliminated"]
},
GoldenSetId = "gs-lodash-12345",
VerifiedAt = DateTimeOffset.UtcNow
};
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-lodash-12345", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateApprovedGoldenSet());
var parameters = new FixChainGateParameters
{
Severities = ["critical", "high"],
MinConfidence = 0.90m,
RequireApprovedGoldenSet = true
};
// Act
var result = await predicate.EvaluateAsync(context, parameters);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.FixVerified);
result.Attestation.Should().NotBeNull();
result.Attestation!.ContentDigest.Should().Be("sha256:abc123");
}
[Fact]
public async Task BatchService_EvaluatesMultipleFindings()
{
// Arrange
var batchService = _serviceProvider.GetRequiredService<IFixChainGateBatchService>();
var contexts = new List<FixChainContext>
{
new()
{
CveId = "CVE-2024-001",
ComponentPurl = "pkg:npm/lodash@4.17.21",
Severity = "critical",
CvssScore = 9.8m
},
new()
{
CveId = "CVE-2024-002",
ComponentPurl = "pkg:npm/axios@0.21.0",
Severity = "high",
CvssScore = 7.5m
},
new()
{
CveId = "CVE-2024-003",
ComponentPurl = "pkg:npm/moment@2.29.0",
Severity = "low",
CvssScore = 3.0m
}
};
// First CVE has attestation
_attestationClientMock
.Setup(x => x.GetFixChainAsync("CVE-2024-001", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateAttestation("CVE-2024-001", "fixed", 0.95m));
// Second CVE has no attestation
_attestationClientMock
.Setup(x => x.GetFixChainAsync("CVE-2024-002", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
// Third CVE doesn't need one (low severity)
_attestationClientMock
.Setup(x => x.GetFixChainAsync("CVE-2024-003", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
_goldenSetStoreMock
.Setup(x => x.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateApprovedGoldenSet());
var parameters = new FixChainGateParameters
{
Severities = ["critical", "high"],
MinConfidence = 0.90m,
RequireApprovedGoldenSet = true
};
// Act
var result = await batchService.EvaluateBatchAsync(contexts, parameters);
// Assert
result.Results.Should().HaveCount(3);
result.AllPassed.Should().BeFalse(); // CVE-2024-002 should block
result.Results["CVE-2024-001"].Passed.Should().BeTrue();
result.Results["CVE-2024-002"].Passed.Should().BeFalse();
result.Results["CVE-2024-003"].Passed.Should().BeTrue(); // Low severity exempt
result.BlockingResults.Should().HaveCount(1);
result.AggregatedRecommendations.Should().NotBeEmpty();
}
[Fact]
public async Task GateAdapter_IntegratesWithPolicyGateRegistry()
{
// Arrange
var registry = new PolicyGateRegistry(_serviceProvider);
registry.RegisterFixChainGate();
// Setup attestation for the CVE
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateAttestation("CVE-2024-TEST", "fixed", 0.95m));
_goldenSetStoreMock
.Setup(x => x.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateApprovedGoldenSet());
var mergeResult = new MergeResult
{
Status = VexStatus.Affected,
Confidence = 0.9,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 0.9,
AdjustedScore = 0.9,
ScopeSpecificity = 1,
Accepted = true,
Reason = "Test claim"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
var context = new PolicyGateContext
{
CveId = "CVE-2024-TEST",
SubjectKey = "pkg:npm/test@1.0.0",
Severity = "critical",
Environment = "production"
};
// Act
var result = await registry.EvaluateAsync(mergeResult, context);
// Assert
result.Results.Should().HaveCount(1);
result.Results[0].GateName.Should().Be(nameof(FixChainGateAdapter));
result.Results[0].Passed.Should().BeTrue();
}
[Fact]
public async Task Notifier_SendsNotificationsOnBlock()
{
// Arrange
var channelMock = new Mock<INotificationChannel>();
var notifier = new FixChainGateNotifier(
NullLogger<FixChainGateNotifier>.Instance,
[channelMock.Object]);
var notification = new GateBlockedNotification
{
CveId = "CVE-2024-12345",
Component = "pkg:npm/lodash@4.17.21",
Severity = "critical",
Reason = "No FixChain attestation found",
Outcome = FixChainGateOutcome.AttestationRequired,
Recommendations = ["Create golden set"],
CliCommands = ["stella scanner golden init --cve CVE-2024-12345"],
PolicyName = "release-gates",
BlockedAt = DateTimeOffset.UtcNow
};
// Act
await notifier.NotifyGateBlockedAsync(notification);
// Assert
channelMock.Verify(
x => x.SendAsync(
"fixchain_gate_blocked",
It.IsAny<NotificationMessage>(),
NotificationSeverity.Error,
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ServiceRegistration_ResolvesAllServices()
{
// Assert all services are resolvable
var predicate = _serviceProvider.GetService<IFixChainGatePredicate>();
predicate.Should().NotBeNull();
var batchService = _serviceProvider.GetService<IFixChainGateBatchService>();
batchService.Should().NotBeNull();
var adapter = _serviceProvider.GetService<FixChainGateAdapter>();
adapter.Should().NotBeNull();
}
[Fact]
public async Task GracePeriod_AllowsRecentCVEs()
{
// Arrange
var predicate = _serviceProvider.GetRequiredService<IFixChainGatePredicate>();
var context = new FixChainContext
{
CveId = "CVE-2024-NEW",
ComponentPurl = "pkg:npm/new-package@1.0.0",
Severity = "critical",
CvssScore = 9.8m,
CvePublishedAt = DateTimeOffset.UtcNow.AddDays(-3) // 3 days ago
};
var parameters = new FixChainGateParameters
{
Severities = ["critical"],
GracePeriodDays = 7 // 7 day grace period
};
// Act
var result = await predicate.EvaluateAsync(context, parameters);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.GracePeriod);
}
#region Helpers
private static FixChainAttestationData CreateAttestation(
string cveId,
string status,
decimal confidence)
{
return new FixChainAttestationData
{
ContentDigest = $"sha256:{cveId}",
CveId = cveId,
ComponentPurl = "pkg:npm/test@1.0.0",
BinarySha256 = new string('a', 64),
Verdict = new FixChainVerdictData
{
Status = status,
Confidence = confidence,
Rationale = ["Test rationale"]
},
GoldenSetId = $"gs-{cveId}",
VerifiedAt = DateTimeOffset.UtcNow
};
}
private static StoredGoldenSet CreateApprovedGoldenSet()
{
return new StoredGoldenSet
{
Definition = new GoldenSetDefinition
{
Id = "gs-test",
Component = "test",
Targets = [],
Metadata = new GoldenSetMetadata
{
AuthorId = "test-author",
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
SourceRef = "https://example.com",
ReviewedBy = "reviewer"
}
},
Status = GoldenSetStatus.Approved,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
UpdatedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -19,6 +19,7 @@
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Predicates/StellaOps.Policy.Predicates.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />

View File

@@ -0,0 +1,500 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
// Sprint: SPRINT_20260110_012_008_POLICY
// Task: FCG-008 - Unit Tests
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.BinaryIndex.GoldenSet;
using StellaOps.Policy.Predicates.FixChain;
using StellaOps.RiskEngine.Core.Providers.FixChain;
using Xunit;
namespace StellaOps.Policy.Tests.Unit.Predicates;
[Trait("Category", "Unit")]
public sealed class FixChainGatePredicateTests
{
private readonly Mock<IFixChainAttestationClient> _attestationClientMock;
private readonly Mock<IGoldenSetStore> _goldenSetStoreMock;
private readonly FakeTimeProvider _timeProvider;
private readonly FixChainGatePredicate _predicate;
private readonly FixChainGateParameters _defaultParams;
public FixChainGatePredicateTests()
{
_attestationClientMock = new Mock<IFixChainAttestationClient>();
_goldenSetStoreMock = new Mock<IGoldenSetStore>();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
var options = new OptionsMonitor<FixChainGateOptions>(new FixChainGateOptions { Enabled = true });
_predicate = new FixChainGatePredicate(
_attestationClientMock.Object,
_goldenSetStoreMock.Object,
options,
NullLogger<FixChainGatePredicate>.Instance,
_timeProvider);
_defaultParams = new FixChainGateParameters
{
Severities = ["critical", "high"],
MinConfidence = 0.85m,
AllowInconclusive = false,
GracePeriodDays = 7,
RequireApprovedGoldenSet = true
};
}
[Fact]
public async Task Evaluate_SeverityExempt_Passes()
{
// Arrange - Low severity when gate only requires critical/high
var context = CreateContext(severity: "low");
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.SeverityExempt);
result.Reason.Should().Contain("does not require fix verification");
}
[Fact]
public async Task Evaluate_MediumSeverityExempt_Passes()
{
// Arrange
var context = CreateContext(severity: "medium");
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.SeverityExempt);
}
[Fact]
public async Task Evaluate_GracePeriod_Passes()
{
// Arrange - CVE published 3 days ago, grace period is 7 days
var context = CreateContext(
severity: "critical",
cvePublishedAt: _timeProvider.GetUtcNow().AddDays(-3));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.GracePeriod);
result.Recommendations.Should().NotBeEmpty();
result.CliCommands.Should().NotBeEmpty();
}
[Fact]
public async Task Evaluate_GracePeriodExpired_RequiresAttestation()
{
// Arrange - CVE published 10 days ago, grace period is 7 days
var context = CreateContext(
severity: "critical",
cvePublishedAt: _timeProvider.GetUtcNow().AddDays(-10));
// No attestation
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.AttestationRequired);
}
[Fact]
public async Task Evaluate_NoAttestation_Blocks()
{
// Arrange - Critical CVE without attestation (no grace period)
var context = CreateContext(severity: "critical");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.AttestationRequired);
result.Reason.Should().Contain("No FixChain attestation found");
result.Recommendations.Should().Contain(r => r.Contains("Create golden set"));
result.CliCommands.Should().NotBeEmpty();
}
[Fact]
public async Task Evaluate_FixedHighConfidence_Passes()
{
// Arrange - Fixed verdict with 97% confidence
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("fixed", 0.97m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.FixVerified);
result.Attestation.Should().NotBeNull();
result.Attestation!.Confidence.Should().Be(0.97m);
}
[Fact]
public async Task Evaluate_FixedLowConfidence_Blocks()
{
// Arrange - Fixed verdict with 70% confidence when 85% required
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("fixed", 0.70m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.InsufficientConfidence);
result.Reason.Should().Contain("70%").And.Contain("85%");
result.Recommendations.Should().Contain(r => r.Contains("completeness"));
}
[Fact]
public async Task Evaluate_InconclusiveAllowed_Passes()
{
// Arrange
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("inconclusive", 0.50m, "gs-test");
var paramsAllowInconclusive = _defaultParams with { AllowInconclusive = true };
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, paramsAllowInconclusive);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Contain("allowed by policy");
}
[Fact]
public async Task Evaluate_InconclusiveNotAllowed_Blocks()
{
// Arrange
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("inconclusive", 0.50m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.InconclusiveNotAllowed);
}
[Fact]
public async Task Evaluate_StillVulnerable_Blocks()
{
// Arrange
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("still_vulnerable", 0.95m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.StillVulnerable);
result.Reason.Should().Contain("still present");
}
[Fact]
public async Task Evaluate_GoldenSetNotApproved_Blocks()
{
// Arrange - Draft golden set when approval required
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("fixed", 0.95m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Draft));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.GoldenSetNotApproved);
result.Reason.Should().Contain("not been reviewed");
}
[Fact]
public async Task Evaluate_GoldenSetApprovalNotRequired_SkipsCheck()
{
// Arrange
var context = CreateContext(severity: "critical");
var attestation = CreateAttestation("fixed", 0.95m, "gs-test");
var paramsNoApproval = _defaultParams with { RequireApprovedGoldenSet = false };
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
// No golden set lookup should happen
_goldenSetStoreMock
.Setup(x => x.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((StoredGoldenSet?)null);
// Act
var result = await _predicate.EvaluateAsync(context, paramsNoApproval);
// Assert
result.Passed.Should().BeTrue();
result.Outcome.Should().Be(FixChainGateOutcome.FixVerified);
}
[Fact]
public async Task Evaluate_PartialFix_UsesInconclusivePolicy()
{
// Arrange
var context = CreateContext(severity: "high");
var attestation = CreateAttestation("partial", 0.75m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act - AllowInconclusive = false by default
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.PartialFix);
}
[Fact]
public async Task Evaluate_HighSeverity_RequiresVerification()
{
// Arrange
var context = CreateContext(severity: "high");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.AttestationRequired);
}
[Theory]
[InlineData("CRITICAL")]
[InlineData("Critical")]
[InlineData("critical")]
public async Task Evaluate_SeverityCaseInsensitive_Works(string severity)
{
// Arrange
var context = CreateContext(severity: severity);
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((FixChainAttestationData?)null);
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeFalse();
result.Outcome.Should().Be(FixChainGateOutcome.AttestationRequired);
}
[Fact]
public async Task Evaluate_MetadataPopulated_OnVerifiedFix()
{
// Arrange
var metadata = new Dictionary<string, string>();
var context = CreateContext(severity: "critical") with { Metadata = metadata };
var attestation = CreateAttestation("fixed", 0.95m, "gs-test");
_attestationClientMock
.Setup(x => x.GetFixChainAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(attestation);
_goldenSetStoreMock
.Setup(x => x.GetAsync("gs-test", It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateStoredGoldenSet(GoldenSetStatus.Approved));
// Act
var result = await _predicate.EvaluateAsync(context, _defaultParams);
// Assert
result.Passed.Should().BeTrue();
metadata.Should().ContainKey("fixchain_digest");
metadata.Should().ContainKey("fixchain_confidence");
}
[Fact]
public async Task PredicateId_ReturnsCorrectValue()
{
_predicate.PredicateId.Should().Be("fixChainRequired");
}
#region Helpers
private static FixChainGateContext CreateContext(
string severity = "critical",
string cveId = "CVE-2024-12345",
string componentPurl = "pkg:npm/lodash@4.17.21",
DateTimeOffset? cvePublishedAt = null)
{
return new FixChainGateContext
{
CveId = cveId,
ComponentPurl = componentPurl,
Severity = severity,
CvssScore = 8.5m,
BinarySha256 = new string('a', 64),
CvePublishedAt = cvePublishedAt
};
}
private static FixChainAttestationData CreateAttestation(
string status,
decimal confidence,
string? goldenSetId = null)
{
return new FixChainAttestationData
{
ContentDigest = "sha256:abc123",
CveId = "CVE-2024-12345",
ComponentPurl = "pkg:npm/lodash@4.17.21",
BinarySha256 = new string('a', 64),
Verdict = new FixChainVerdictData
{
Status = status,
Confidence = confidence,
Rationale = ["Test rationale"]
},
GoldenSetId = goldenSetId,
VerifiedAt = DateTimeOffset.UtcNow
};
}
private static StoredGoldenSet CreateStoredGoldenSet(GoldenSetStatus status)
{
return new StoredGoldenSet
{
Definition = new GoldenSetDefinition
{
Id = "CVE-2024-12345",
Component = "lodash",
Targets = [],
Metadata = new GoldenSetMetadata
{
AuthorId = "test-author",
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
SourceRef = "https://example.com/advisory",
ReviewedBy = status == GoldenSetStatus.Approved ? "reviewer" : null
}
},
Status = status,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
UpdatedAt = DateTimeOffset.UtcNow
};
}
#endregion
}
/// <summary>
/// Fake TimeProvider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
/// <summary>
/// Simple options monitor for testing.
/// </summary>
internal sealed class OptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
public OptionsMonitor(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}