465 lines
14 KiB
C#
465 lines
14 KiB
C#
// Licensed under BUSL-1.1. 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 = []
|
|
};
|
|
}
|
|
}
|