feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,134 @@
using FluentAssertions;
using StellaOps.Cryptography;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Snapshots;
using Xunit;
namespace StellaOps.Policy.Tests.Deltas;
public sealed class BaselineSelectorTests
{
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
private readonly InMemorySnapshotStore _snapshotStore = new();
private readonly BaselineSelector _selector;
public BaselineSelectorTests()
{
_selector = new BaselineSelector(_snapshotStore);
}
[Fact]
public async Task SelectExplicit_ValidSnapshot_ReturnsSuccess()
{
var snapshot = await CreateAndSaveSnapshotAsync();
var result = await _selector.SelectExplicitAsync(snapshot.SnapshotId);
result.IsFound.Should().BeTrue();
result.Snapshot.Should().NotBeNull();
result.Strategy.Should().Be(BaselineSelectionStrategy.Explicit);
}
[Fact]
public async Task SelectExplicit_NonExistent_ReturnsNotFound()
{
var result = await _selector.SelectExplicitAsync("ksm:sha256:nonexistent");
result.IsFound.Should().BeFalse();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task SelectExplicit_EmptyId_ReturnsNotFound()
{
var result = await _selector.SelectExplicitAsync("");
result.IsFound.Should().BeFalse();
result.Error.Should().Contain("required");
}
[Fact]
public async Task SelectBaseline_PreviousBuild_NoSnapshots_ReturnsNotFound()
{
var result = await _selector.SelectBaselineAsync(
"sha256:artifact",
BaselineSelectionStrategy.PreviousBuild);
result.IsFound.Should().BeFalse();
}
[Fact]
public async Task SelectBaseline_PreviousBuild_WithSnapshots_ReturnsSecond()
{
// Create multiple snapshots
await CreateAndSaveSnapshotAsync();
await Task.Delay(10); // Ensure different timestamps
await CreateAndSaveSnapshotAsync();
var result = await _selector.SelectBaselineAsync(
"sha256:artifact",
BaselineSelectionStrategy.PreviousBuild);
result.IsFound.Should().BeTrue();
}
[Fact]
public async Task SelectBaseline_LastApproved_NoSnapshots_ReturnsNotFound()
{
var result = await _selector.SelectBaselineAsync(
"sha256:artifact",
BaselineSelectionStrategy.LastApproved);
result.IsFound.Should().BeFalse();
}
[Fact]
public async Task SelectBaseline_LastApproved_WithSealed_ReturnsSealedFirst()
{
// Create unsigned snapshot
await CreateAndSaveSnapshotAsync();
// Create sealed snapshot
var sealedSnapshot = await CreateAndSaveSnapshotAsync();
var sealedWithSig = sealedSnapshot with { Signature = "test-signature" };
await _snapshotStore.SaveAsync(sealedWithSig);
var result = await _selector.SelectBaselineAsync(
"sha256:artifact",
BaselineSelectionStrategy.LastApproved);
result.IsFound.Should().BeTrue();
result.Snapshot!.Signature.Should().NotBeNull();
}
[Fact]
public async Task SelectBaseline_ExplicitStrategy_ReturnsError()
{
var result = await _selector.SelectBaselineAsync(
"sha256:artifact",
BaselineSelectionStrategy.Explicit);
result.IsFound.Should().BeFalse();
result.Error.Should().Contain("Explicit");
}
private async Task<KnowledgeSnapshotManifest> CreateAndSaveSnapshotAsync()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("stellaops-policy", "1.0.0", "abc123")
.WithPolicy("test-policy", "1.0", "sha256:policy123")
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
.WithSource(new KnowledgeSourceDescriptor
{
Name = $"test-feed-{Guid.NewGuid():N}",
Type = "advisory-feed",
Epoch = DateTimeOffset.UtcNow.ToString("o"),
Digest = $"sha256:{Guid.NewGuid():N}",
InclusionMode = SourceInclusionMode.Referenced
});
var manifest = builder.Build();
await _snapshotStore.SaveAsync(manifest);
return manifest;
}
}

View File

@@ -0,0 +1,152 @@
using FluentAssertions;
using StellaOps.Policy.Deltas;
using Xunit;
namespace StellaOps.Policy.Tests.Deltas;
public sealed class DeltaVerdictTests
{
[Fact]
public void Build_WithNoDrivers_ReturnsPass()
{
var verdict = new DeltaVerdictBuilder()
.Build("delta:sha256:test");
verdict.Status.Should().Be(DeltaVerdictStatus.Pass);
verdict.Explanation.Should().Contain("No blocking");
}
[Fact]
public void Build_WithWarningDriver_ReturnsWarn()
{
var driver = new DeltaDriver
{
Type = "new-package",
Severity = DeltaDriverSeverity.Low,
Description = "New package added"
};
var verdict = new DeltaVerdictBuilder()
.AddWarningDriver(driver)
.Build("delta:sha256:test");
verdict.Status.Should().Be(DeltaVerdictStatus.Warn);
verdict.WarningDrivers.Should().HaveCount(1);
}
[Fact]
public void Build_WithBlockingDriver_ReturnsFail()
{
var driver = new DeltaDriver
{
Type = "new-reachable-cve",
Severity = DeltaDriverSeverity.Critical,
Description = "Critical CVE is now reachable",
CveId = "CVE-2024-001"
};
var verdict = new DeltaVerdictBuilder()
.AddBlockingDriver(driver)
.Build("delta:sha256:test");
verdict.Status.Should().Be(DeltaVerdictStatus.Fail);
verdict.BlockingDrivers.Should().HaveCount(1);
verdict.RecommendedGate.Should().Be(DeltaGateLevel.G4);
}
[Fact]
public void Build_WithBlockingDriverAndException_ReturnsPassWithExceptions()
{
var driver = new DeltaDriver
{
Type = "new-reachable-cve",
Severity = DeltaDriverSeverity.Critical,
Description = "Critical CVE is now reachable",
CveId = "CVE-2024-001"
};
var verdict = new DeltaVerdictBuilder()
.AddBlockingDriver(driver)
.AddException("exception-123")
.Build("delta:sha256:test");
verdict.Status.Should().Be(DeltaVerdictStatus.PassWithExceptions);
verdict.AppliedExceptions.Should().Contain("exception-123");
}
[Fact]
public void Build_CriticalDriver_EscalatesToG4()
{
var driver = new DeltaDriver
{
Type = "critical-issue",
Severity = DeltaDriverSeverity.Critical,
Description = "Critical issue"
};
var verdict = new DeltaVerdictBuilder()
.AddBlockingDriver(driver)
.Build("delta:sha256:test");
verdict.RecommendedGate.Should().Be(DeltaGateLevel.G4);
}
[Fact]
public void Build_HighDriver_EscalatesToG3()
{
var driver = new DeltaDriver
{
Type = "high-issue",
Severity = DeltaDriverSeverity.High,
Description = "High severity issue"
};
var verdict = new DeltaVerdictBuilder()
.AddBlockingDriver(driver)
.Build("delta:sha256:test");
verdict.RecommendedGate.Should().Be(DeltaGateLevel.G3);
}
[Fact]
public void Build_WithRiskPoints_SetsCorrectValue()
{
var verdict = new DeltaVerdictBuilder()
.WithRiskPoints(25)
.Build("delta:sha256:test");
verdict.RiskPoints.Should().Be(25);
}
[Fact]
public void Build_WithRecommendations_IncludesAll()
{
var verdict = new DeltaVerdictBuilder()
.AddRecommendation("Review CVE-2024-001")
.AddRecommendation("Update dependency")
.Build("delta:sha256:test");
verdict.Recommendations.Should().HaveCount(2);
verdict.Recommendations.Should().Contain("Review CVE-2024-001");
}
[Fact]
public void Build_WithCustomExplanation_UsesProvided()
{
var verdict = new DeltaVerdictBuilder()
.WithExplanation("Custom explanation")
.Build("delta:sha256:test");
verdict.Explanation.Should().Be("Custom explanation");
}
[Fact]
public void Build_GeneratesUniqueVerdictId()
{
var verdict1 = new DeltaVerdictBuilder().Build("delta:sha256:test");
var verdict2 = new DeltaVerdictBuilder().Build("delta:sha256:test");
verdict1.VerdictId.Should().StartWith("dv:");
verdict1.VerdictId.Should().NotBe(verdict2.VerdictId);
}
}

View File

@@ -0,0 +1,98 @@
using FluentAssertions;
using StellaOps.Policy.Deltas;
using Xunit;
namespace StellaOps.Policy.Tests.Deltas;
public sealed class SecurityStateDeltaTests
{
[Fact]
public void SecurityStateDelta_CanBeCreated()
{
var delta = new SecurityStateDelta
{
DeltaId = "delta:sha256:test123",
ComputedAt = DateTimeOffset.UtcNow,
BaselineSnapshotId = "ksm:sha256:baseline",
TargetSnapshotId = "ksm:sha256:target",
Artifact = new ArtifactRef("sha256:artifact", "test-image", "v1.0"),
Sbom = SbomDelta.Empty,
Reachability = ReachabilityDelta.Empty,
Vex = VexDelta.Empty,
Policy = PolicyDelta.Empty,
Unknowns = UnknownsDelta.Empty,
Summary = DeltaSummary.Empty
};
delta.DeltaId.Should().StartWith("delta:");
delta.Artifact.Digest.Should().Be("sha256:artifact");
}
[Fact]
public void SbomDelta_TracksPackageChanges()
{
var delta = new SbomDelta
{
PackagesAdded = 5,
PackagesRemoved = 2,
PackagesModified = 1,
AddedPackages = new[]
{
new PackageChange("pkg:npm/foo@1.0", "MIT"),
new PackageChange("pkg:npm/bar@2.0", "Apache-2.0")
}
};
delta.PackagesAdded.Should().Be(5);
delta.AddedPackages.Should().HaveCount(2);
}
[Fact]
public void ReachabilityDelta_TracksChanges()
{
var delta = new ReachabilityDelta
{
NewReachable = 3,
NewUnreachable = 1,
Changes = new[]
{
new ReachabilityChange("CVE-2024-001", "pkg:npm/foo@1.0", false, true)
}
};
delta.NewReachable.Should().Be(3);
delta.Changes.First().IsReachable.Should().BeTrue();
}
[Fact]
public void DeltaDriver_HasCorrectSeverity()
{
var driver = new DeltaDriver
{
Type = "new-reachable-cve",
Severity = DeltaDriverSeverity.Critical,
Description = "CVE-2024-001 is now reachable",
CveId = "CVE-2024-001"
};
driver.Severity.Should().Be(DeltaDriverSeverity.Critical);
driver.Type.Should().Be("new-reachable-cve");
}
[Fact]
public void DeltaSummary_TracksRiskDirection()
{
var summary = new DeltaSummary
{
TotalChanges = 10,
RiskIncreasing = 5,
RiskDecreasing = 2,
Neutral = 3,
RiskScore = 15.5m,
RiskDirection = "increasing"
};
summary.RiskDirection.Should().Be("increasing");
summary.RiskScore.Should().Be(15.5m);
}
}

View File

@@ -0,0 +1,123 @@
using FluentAssertions;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public sealed class BudgetLedgerTests
{
private readonly InMemoryBudgetStore _store = new();
private readonly BudgetLedger _ledger;
private readonly string _currentWindow;
public BudgetLedgerTests()
{
_ledger = new BudgetLedger(_store);
_currentWindow = DateTimeOffset.UtcNow.ToString("yyyy-MM");
}
[Fact]
public async Task GetBudget_CreatesDefaultWhenNotExists()
{
var budget = await _ledger.GetBudgetAsync("new-service");
budget.Should().NotBeNull();
budget.ServiceId.Should().Be("new-service");
budget.Tier.Should().Be(ServiceTier.CustomerFacingNonCritical);
budget.Allocated.Should().Be(200); // Default for Tier 1
budget.Consumed.Should().Be(0);
}
[Fact]
public async Task GetBudget_ReturnsExistingBudget()
{
var existing = CreateBudget("existing-service", consumed: 50);
await _store.CreateAsync(existing, CancellationToken.None);
var budget = await _ledger.GetBudgetAsync("existing-service", _currentWindow);
budget.Consumed.Should().Be(50);
}
[Fact]
public async Task Consume_DeductsBudget()
{
var initial = CreateBudget("test-service", consumed: 50);
await _store.CreateAsync(initial, CancellationToken.None);
var result = await _ledger.ConsumeAsync("test-service", 20, "release-1");
result.IsSuccess.Should().BeTrue();
result.Budget.Consumed.Should().Be(70);
result.Budget.Remaining.Should().Be(130);
result.Entry.Should().NotBeNull();
result.Entry!.RiskPoints.Should().Be(20);
}
[Fact]
public async Task Consume_FailsWhenInsufficientBudget()
{
var initial = CreateBudget("test-service", consumed: 190);
await _store.CreateAsync(initial, CancellationToken.None);
var result = await _ledger.ConsumeAsync("test-service", 20, "release-1");
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Insufficient");
}
[Fact]
public async Task GetHistory_ReturnsEntries()
{
await _ledger.GetBudgetAsync("test-service");
await _ledger.ConsumeAsync("test-service", 10, "release-1");
await _ledger.ConsumeAsync("test-service", 15, "release-2");
var history = await _ledger.GetHistoryAsync("test-service");
history.Should().HaveCount(2);
history.Should().Contain(e => e.ReleaseId == "release-1");
history.Should().Contain(e => e.ReleaseId == "release-2");
}
[Fact]
public async Task AdjustAllocation_IncreasesCapacity()
{
await _ledger.GetBudgetAsync("test-service");
var adjusted = await _ledger.AdjustAllocationAsync("test-service", 50, "earned capacity");
adjusted.Allocated.Should().Be(250); // 200 + 50
}
[Fact]
public async Task AdjustAllocation_DecreasesCapacity()
{
await _ledger.GetBudgetAsync("test-service");
var adjusted = await _ledger.AdjustAllocationAsync("test-service", -50, "incident penalty");
adjusted.Allocated.Should().Be(150); // 200 - 50
}
[Fact]
public async Task AdjustAllocation_DoesNotGoBelowZero()
{
await _ledger.GetBudgetAsync("test-service");
var adjusted = await _ledger.AdjustAllocationAsync("test-service", -500, "major penalty");
adjusted.Allocated.Should().Be(0);
}
private RiskBudget CreateBudget(string serviceId, int consumed) => new()
{
BudgetId = $"budget:{serviceId}:{_currentWindow}",
ServiceId = serviceId,
Tier = ServiceTier.CustomerFacingNonCritical,
Window = _currentWindow,
Allocated = 200,
Consumed = consumed,
UpdatedAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,78 @@
using FluentAssertions;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public sealed class GateLevelTests
{
[Theory]
[InlineData(GateLevel.G0, 2)]
[InlineData(GateLevel.G1, 5)]
[InlineData(GateLevel.G2, 6)]
[InlineData(GateLevel.G3, 7)]
[InlineData(GateLevel.G4, 6)]
public void GetRequirements_ReturnsCorrectCount(GateLevel level, int expectedCount)
{
var requirements = GateLevelRequirements.GetRequirements(level);
requirements.Should().HaveCount(expectedCount);
}
[Fact]
public void GetRequirements_G0_HasBasicCiOnly()
{
var requirements = GateLevelRequirements.GetRequirements(GateLevel.G0);
requirements.Should().Contain(r => r.Contains("Lint"));
requirements.Should().Contain(r => r.Contains("CI"));
}
[Fact]
public void GetRequirements_G1_HasUnitTestsAndReview()
{
var requirements = GateLevelRequirements.GetRequirements(GateLevel.G1);
requirements.Should().Contain(r => r.Contains("unit tests"));
requirements.Should().Contain(r => r.Contains("peer review"));
}
[Fact]
public void GetRequirements_G2_IncludesG1Requirements()
{
var requirements = GateLevelRequirements.GetRequirements(GateLevel.G2);
requirements.Should().Contain(r => r.Contains("G1"));
requirements.Should().Contain(r => r.Contains("Code owner", StringComparison.OrdinalIgnoreCase));
requirements.Should().Contain(r => r.Contains("feature flag", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetRequirements_G3_HasSecurityAndReleaseSign()
{
var requirements = GateLevelRequirements.GetRequirements(GateLevel.G3);
requirements.Should().Contain(r => r.Contains("Security scan", StringComparison.OrdinalIgnoreCase));
requirements.Should().Contain(r => r.Contains("release captain", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetRequirements_G4_HasFormalReviewAndCanary()
{
var requirements = GateLevelRequirements.GetRequirements(GateLevel.G4);
requirements.Should().Contain(r => r.Contains("Formal risk review"));
requirements.Should().Contain(r => r.Contains("Extended canary"));
}
[Theory]
[InlineData(GateLevel.G0, "No-risk")]
[InlineData(GateLevel.G1, "Low risk")]
[InlineData(GateLevel.G2, "Moderate risk")]
[InlineData(GateLevel.G3, "High risk")]
[InlineData(GateLevel.G4, "Very high risk")]
public void GetDescription_ContainsExpectedText(GateLevel level, string expectedText)
{
var description = GateLevelRequirements.GetDescription(level);
description.Should().Contain(expectedText);
}
}

View File

@@ -0,0 +1,85 @@
using FluentAssertions;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public sealed class RiskBudgetTests
{
[Fact]
public void Budget_WithNoConsumption_IsGreen()
{
var budget = CreateBudget(allocated: 200, consumed: 0);
budget.Status.Should().Be(BudgetStatus.Green);
budget.Remaining.Should().Be(200);
budget.PercentageUsed.Should().Be(0);
}
[Fact]
public void Budget_With30PercentUsed_IsGreen()
{
var budget = CreateBudget(allocated: 200, consumed: 60);
budget.Status.Should().Be(BudgetStatus.Green);
budget.PercentageUsed.Should().Be(30);
}
[Fact]
public void Budget_With40PercentUsed_IsYellow()
{
var budget = CreateBudget(allocated: 200, consumed: 80);
budget.Status.Should().Be(BudgetStatus.Yellow);
budget.PercentageUsed.Should().Be(40);
}
[Fact]
public void Budget_With70PercentUsed_IsRed()
{
var budget = CreateBudget(allocated: 200, consumed: 140);
budget.Status.Should().Be(BudgetStatus.Red);
budget.PercentageUsed.Should().Be(70);
}
[Fact]
public void Budget_With100PercentUsed_IsExhausted()
{
var budget = CreateBudget(allocated: 200, consumed: 200);
budget.Status.Should().Be(BudgetStatus.Exhausted);
budget.Remaining.Should().Be(0);
}
[Fact]
public void Budget_Overconsumed_IsExhausted()
{
var budget = CreateBudget(allocated: 200, consumed: 250);
budget.Status.Should().Be(BudgetStatus.Exhausted);
budget.Remaining.Should().Be(-50);
}
[Theory]
[InlineData(ServiceTier.Internal, 300)]
[InlineData(ServiceTier.CustomerFacingNonCritical, 200)]
[InlineData(ServiceTier.CustomerFacingCritical, 120)]
[InlineData(ServiceTier.SafetyCritical, 80)]
public void DefaultAllocations_AreCorrect(ServiceTier tier, int expected)
{
var allocation = DefaultBudgetAllocations.GetMonthlyAllocation(tier);
allocation.Should().Be(expected);
}
private static RiskBudget CreateBudget(int allocated, int consumed) => new()
{
BudgetId = "budget:test:2025-01",
ServiceId = "test-service",
Tier = ServiceTier.CustomerFacingNonCritical,
Window = "2025-01",
Allocated = allocated,
Consumed = consumed,
UpdatedAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,173 @@
using FluentAssertions;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public sealed class RiskPointScoringTests
{
private readonly RiskPointScoring _scoring = new();
[Theory]
[InlineData(ServiceTier.Internal, 1)]
[InlineData(ServiceTier.CustomerFacingNonCritical, 3)]
[InlineData(ServiceTier.CustomerFacingCritical, 6)]
[InlineData(ServiceTier.SafetyCritical, 10)]
public void CalculateScore_UsesCorrectBaseScore(ServiceTier tier, int expectedBase)
{
var input = CreateInput(tier, DiffCategory.DocsOnly);
var result = _scoring.CalculateScore(input);
result.Breakdown.Base.Should().Be(expectedBase);
}
[Theory]
[InlineData(DiffCategory.DocsOnly, 1)]
[InlineData(DiffCategory.UiNonCore, 3)]
[InlineData(DiffCategory.ApiBackwardCompatible, 6)]
[InlineData(DiffCategory.DatabaseMigration, 10)]
[InlineData(DiffCategory.CryptoPayment, 15)]
public void CalculateScore_UsesCorrectDiffRisk(DiffCategory category, int expectedDiffRisk)
{
var input = CreateInput(ServiceTier.Internal, category);
var result = _scoring.CalculateScore(input);
result.Breakdown.DiffRisk.Should().Be(expectedDiffRisk);
}
[Fact]
public void CalculateScore_AddsOperationalContext()
{
var input = CreateInput(
ServiceTier.CustomerFacingNonCritical,
DiffCategory.DocsOnly,
context: new OperationalContext
{
HasRecentIncident = true,
ErrorBudgetBelow50Percent = true
});
var result = _scoring.CalculateScore(input);
result.Breakdown.OperationalContext.Should().Be(8); // 5 + 3
}
[Fact]
public void CalculateScore_SubtractsMitigations()
{
var input = CreateInput(
ServiceTier.CustomerFacingNonCritical,
DiffCategory.ApiBackwardCompatible,
mitigations: new MitigationFactors
{
HasFeatureFlag = true,
HasCanaryDeployment = true
});
var result = _scoring.CalculateScore(input);
result.Breakdown.Mitigations.Should().Be(6); // 3 + 3
}
[Fact]
public void CalculateScore_MinimumIsOne()
{
var input = CreateInput(
ServiceTier.Internal,
DiffCategory.DocsOnly,
mitigations: new MitigationFactors
{
HasFeatureFlag = true,
HasCanaryDeployment = true,
HasHighTestCoverage = true
});
var result = _scoring.CalculateScore(input);
result.Score.Should().Be(1);
}
[Theory]
[InlineData(5, GateLevel.G1)]
[InlineData(6, GateLevel.G2)]
[InlineData(12, GateLevel.G2)]
[InlineData(13, GateLevel.G3)]
[InlineData(20, GateLevel.G3)]
[InlineData(21, GateLevel.G4)]
public void CalculateScore_DeterminesCorrectGateLevel(int targetScore, GateLevel expectedGate)
{
// Use Tier 0 (base=1) + appropriate diff to hit target
var diffCategory = targetScore switch
{
<= 5 => DiffCategory.UiNonCore, // 1 + 3 = 4
<= 12 => DiffCategory.ApiBackwardCompatible, // 1 + 6 = 7
<= 20 => DiffCategory.InfraNetworking, // 1 + 15 = 16
_ => DiffCategory.CryptoPayment // 1 + 15 = 16, add context to get > 20
};
var context = targetScore > 20
? new OperationalContext { HasRecentIncident = true, InRestrictedWindow = true }
: OperationalContext.Default;
var input = CreateInput(ServiceTier.Internal, diffCategory, context: context);
var result = _scoring.CalculateScore(input);
result.RecommendedGate.Should().Be(expectedGate);
}
[Fact]
public void CalculateScore_EscalatesGateOnYellowBudget()
{
var input = CreateInput(
ServiceTier.CustomerFacingNonCritical,
DiffCategory.ApiBackwardCompatible,
context: new OperationalContext { BudgetStatus = BudgetStatus.Yellow });
var result = _scoring.CalculateScore(input);
// Base=3 + Diff=6 = 9 → G2, but Yellow escalates G2+ → G3
result.RecommendedGate.Should().Be(GateLevel.G3);
}
[Fact]
public void CalculateScore_EscalatesGateOnRedBudget()
{
var input = CreateInput(
ServiceTier.CustomerFacingNonCritical,
DiffCategory.DocsOnly,
context: new OperationalContext { BudgetStatus = BudgetStatus.Red });
var result = _scoring.CalculateScore(input);
// Base=3 + Diff=1 = 4 → G1, but Red escalates G1+ → G2
result.RecommendedGate.Should().Be(GateLevel.G2);
}
[Fact]
public void CalculateScore_MaxGateOnExhaustedBudget()
{
var input = CreateInput(
ServiceTier.Internal,
DiffCategory.DocsOnly,
context: new OperationalContext { BudgetStatus = BudgetStatus.Exhausted });
var result = _scoring.CalculateScore(input);
result.RecommendedGate.Should().Be(GateLevel.G4);
}
private static RiskScoreInput CreateInput(
ServiceTier tier,
DiffCategory category,
OperationalContext? context = null,
MitigationFactors? mitigations = null) => new()
{
Tier = tier,
DiffCategory = category,
Context = context ?? OperationalContext.Default,
Mitigations = mitigations ?? MitigationFactors.None
};
}

View File

@@ -0,0 +1,197 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using StellaOps.Policy.Replay;
using StellaOps.Policy.Snapshots;
using Xunit;
namespace StellaOps.Policy.Tests.Replay;
public sealed class ReplayEngineTests
{
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
private readonly InMemorySnapshotStore _snapshotStore = new();
private readonly SnapshotService _snapshotService;
private readonly ReplayEngine _engine;
public ReplayEngineTests()
{
var idGenerator = new SnapshotIdGenerator(_hasher);
_snapshotService = new SnapshotService(
idGenerator,
_snapshotStore,
NullLogger<SnapshotService>.Instance);
var sourceResolver = new KnowledgeSourceResolver(
_snapshotStore,
NullLogger<KnowledgeSourceResolver>.Instance);
var verdictComparer = new VerdictComparer();
_engine = new ReplayEngine(
_snapshotService,
sourceResolver,
verdictComparer,
NullLogger<ReplayEngine>.Instance);
}
[Fact]
public async Task Replay_ValidSnapshot_ReturnsResult()
{
var snapshot = await CreateSnapshotAsync();
var request = new ReplayRequest
{
ArtifactDigest = "sha256:test123",
SnapshotId = snapshot.SnapshotId
};
var result = await _engine.ReplayAsync(request);
result.Should().NotBeNull();
result.SnapshotId.Should().Be(snapshot.SnapshotId);
result.ReplayedVerdict.Should().NotBeNull();
result.ReplayedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Replay_NonExistentSnapshot_ReturnsReplayFailed()
{
var request = new ReplayRequest
{
ArtifactDigest = "sha256:test123",
SnapshotId = "ksm:sha256:nonexistent"
};
var result = await _engine.ReplayAsync(request);
result.MatchStatus.Should().Be(ReplayMatchStatus.ReplayFailed);
result.DeltaReport.Should().NotBeNull();
result.DeltaReport!.Summary.Should().Contain("not found");
}
[Fact]
public async Task Replay_NoOriginalVerdict_ReturnsNoComparison()
{
var snapshot = await CreateSnapshotAsync();
var request = new ReplayRequest
{
ArtifactDigest = "sha256:test123",
SnapshotId = snapshot.SnapshotId,
OriginalVerdictId = null,
Options = new ReplayOptions { CompareWithOriginal = true }
};
var result = await _engine.ReplayAsync(request);
result.MatchStatus.Should().Be(ReplayMatchStatus.NoComparison);
}
[Fact]
public async Task Replay_SameInputs_ProducesDeterministicResult()
{
var snapshot = await CreateSnapshotAsync();
var request = new ReplayRequest
{
ArtifactDigest = "sha256:determinism-test",
SnapshotId = snapshot.SnapshotId
};
// Run multiple times
var results = new List<ReplayResult>();
for (var i = 0; i < 10; i++)
{
results.Add(await _engine.ReplayAsync(request));
}
// All results should have identical verdicts
var firstScore = results[0].ReplayedVerdict.Score;
var firstDecision = results[0].ReplayedVerdict.Decision;
results.Should().AllSatisfy(r =>
{
r.ReplayedVerdict.Score.Should().Be(firstScore);
r.ReplayedVerdict.Decision.Should().Be(firstDecision);
});
}
[Fact]
public async Task Replay_DifferentArtifacts_ProducesDifferentResults()
{
var snapshot = await CreateSnapshotAsync();
var request1 = new ReplayRequest
{
ArtifactDigest = "sha256:artifact-a",
SnapshotId = snapshot.SnapshotId
};
var request2 = new ReplayRequest
{
ArtifactDigest = "sha256:artifact-b",
SnapshotId = snapshot.SnapshotId
};
var result1 = await _engine.ReplayAsync(request1);
var result2 = await _engine.ReplayAsync(request2);
// Different inputs may produce different results
// (both are valid, just testing they can differ)
result1.ReplayedVerdict.ArtifactDigest.Should().NotBe(result2.ReplayedVerdict.ArtifactDigest);
}
[Fact]
public async Task Replay_RecordsDuration()
{
var snapshot = await CreateSnapshotAsync();
var request = new ReplayRequest
{
ArtifactDigest = "sha256:test123",
SnapshotId = snapshot.SnapshotId
};
var result = await _engine.ReplayAsync(request);
result.Duration.Should().BeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task Replay_WithValidOriginalVerdictId_AttemptsComparison()
{
var snapshot = await CreateSnapshotAsync();
var request = new ReplayRequest
{
ArtifactDigest = "sha256:test123",
SnapshotId = snapshot.SnapshotId,
OriginalVerdictId = "verdict-not-found",
Options = new ReplayOptions { CompareWithOriginal = true }
};
var result = await _engine.ReplayAsync(request);
// Original verdict not implemented in test, so no comparison
result.MatchStatus.Should().Be(ReplayMatchStatus.NoComparison);
}
private async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("stellaops-policy", "1.0.0", "abc123")
.WithPolicy("test-policy", "1.0", "sha256:policy123")
.WithScoring("test-scoring", "1.0", "sha256:scoring123")
.WithSource(new KnowledgeSourceDescriptor
{
Name = "test-feed",
Type = "advisory-feed",
Epoch = DateTimeOffset.UtcNow.ToString("o"),
Digest = "sha256:feed123",
InclusionMode = SourceInclusionMode.Referenced
});
return await _snapshotService.CreateSnapshotAsync(builder);
}
}

View File

@@ -0,0 +1,137 @@
using FluentAssertions;
using StellaOps.Policy.Replay;
using Xunit;
namespace StellaOps.Policy.Tests.Replay;
public sealed class ReplayReportTests
{
[Fact]
public void Build_CreatesReportWithRequiredFields()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.ExactMatch);
var report = new ReplayReportBuilder(request, result).Build();
report.ReportId.Should().StartWith("rpt:");
report.ArtifactDigest.Should().Be(request.ArtifactDigest);
report.SnapshotId.Should().Be(request.SnapshotId);
report.MatchStatus.Should().Be(ReplayMatchStatus.ExactMatch);
}
[Fact]
public void Build_ExactMatch_SetsDeterministicTrue()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.ExactMatch);
var report = new ReplayReportBuilder(request, result).Build();
report.IsDeterministic.Should().BeTrue();
report.DeterminismConfidence.Should().Be(1.0m);
}
[Fact]
public void Build_Mismatch_SetsDeterministicFalse()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.Mismatch);
var report = new ReplayReportBuilder(request, result).Build();
report.IsDeterministic.Should().BeFalse();
report.DeterminismConfidence.Should().Be(0.0m);
}
[Fact]
public void Build_MatchWithinTolerance_SetsHighConfidence()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.MatchWithinTolerance);
var report = new ReplayReportBuilder(request, result).Build();
report.IsDeterministic.Should().BeFalse();
report.DeterminismConfidence.Should().Be(0.9m);
}
[Fact]
public void Build_NoComparison_SetsMediumConfidence()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.NoComparison);
var report = new ReplayReportBuilder(request, result).Build();
report.DeterminismConfidence.Should().Be(0.5m);
}
[Fact]
public void AddRecommendation_AddsToList()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.ExactMatch);
var report = new ReplayReportBuilder(request, result)
.AddRecommendation("Test recommendation")
.Build();
report.Recommendations.Should().Contain("Test recommendation");
}
[Fact]
public void AddRecommendationsFromResult_MismatchAddsReviewRecommendation()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.Mismatch);
var report = new ReplayReportBuilder(request, result)
.AddRecommendationsFromResult()
.Build();
report.Recommendations.Should().Contain(r => r.Contains("delta report"));
}
[Fact]
public void AddRecommendationsFromResult_FailedAddsSnapshotRecommendation()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.ReplayFailed);
var report = new ReplayReportBuilder(request, result)
.AddRecommendationsFromResult()
.Build();
report.Recommendations.Should().Contain(r => r.Contains("snapshot"));
}
[Fact]
public void Build_IncludesTiming()
{
var request = CreateRequest();
var result = CreateResult(ReplayMatchStatus.ExactMatch) with
{
Duration = TimeSpan.FromMilliseconds(150)
};
var report = new ReplayReportBuilder(request, result).Build();
report.Timing.TotalDuration.Should().Be(TimeSpan.FromMilliseconds(150));
}
private static ReplayRequest CreateRequest() => new()
{
ArtifactDigest = "sha256:test123",
SnapshotId = "ksm:sha256:snapshot123",
OriginalVerdictId = "verdict-001"
};
private static ReplayResult CreateResult(ReplayMatchStatus status) => new()
{
MatchStatus = status,
ReplayedVerdict = ReplayedVerdict.Empty with { ArtifactDigest = "sha256:test123" },
SnapshotId = "ksm:sha256:snapshot123",
ReplayedAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,127 @@
using FluentAssertions;
using StellaOps.Policy.Replay;
using Xunit;
namespace StellaOps.Policy.Tests.Replay;
public sealed class VerdictComparerTests
{
private readonly VerdictComparer _comparer = new();
[Fact]
public void Compare_IdenticalVerdicts_ReturnsExactMatch()
{
var verdict = CreateVerdict(decision: ReplayDecision.Pass, score: 85.5m);
var result = _comparer.Compare(verdict, verdict, VerdictComparisonOptions.Default);
result.MatchStatus.Should().Be(ReplayMatchStatus.ExactMatch);
result.IsDeterministic.Should().BeTrue();
result.DeterminismConfidence.Should().Be(1.0m);
result.Differences.Should().BeEmpty();
}
[Fact]
public void Compare_DifferentDecisions_ReturnsMismatch()
{
var original = CreateVerdict(decision: ReplayDecision.Pass);
var replayed = CreateVerdict(decision: ReplayDecision.Fail);
var result = _comparer.Compare(replayed, original, VerdictComparisonOptions.Default);
result.MatchStatus.Should().Be(ReplayMatchStatus.Mismatch);
result.IsDeterministic.Should().BeFalse();
result.Differences.Should().Contain(d => d.Field == "Decision");
}
[Fact]
public void Compare_ScoreWithinTolerance_ReturnsMatchWithinTolerance()
{
var original = CreateVerdict(score: 85.5000m);
var replayed = CreateVerdict(score: 85.5005m);
var result = _comparer.Compare(replayed, original,
new VerdictComparisonOptions { ScoreTolerance = 0.001m, TreatMinorAsMatch = true });
result.MatchStatus.Should().Be(ReplayMatchStatus.MatchWithinTolerance);
}
[Fact]
public void Compare_ScoreBeyondTolerance_ReturnsMismatch()
{
var original = CreateVerdict(score: 85.5m);
var replayed = CreateVerdict(score: 86.0m);
var result = _comparer.Compare(replayed, original,
new VerdictComparisonOptions { ScoreTolerance = 0.001m, CriticalScoreTolerance = 0.1m });
result.MatchStatus.Should().Be(ReplayMatchStatus.Mismatch);
result.Differences.Should().Contain(d => d.Field == "Score");
}
[Fact]
public void Compare_DifferentFindings_DetectsAddedAndRemoved()
{
var original = CreateVerdictWithFindings("CVE-2024-001", "CVE-2024-002");
var replayed = CreateVerdictWithFindings("CVE-2024-001", "CVE-2024-003");
var result = _comparer.Compare(replayed, original, VerdictComparisonOptions.Default);
result.MatchStatus.Should().Be(ReplayMatchStatus.Mismatch);
result.Differences.Should().Contain(d => d.Field == "Finding:CVE-2024-002" && d.ReplayedValue == "absent");
result.Differences.Should().Contain(d => d.Field == "Finding:CVE-2024-003" && d.OriginalValue == "absent");
}
[Fact]
public void Compare_SameFindings_DifferentOrder_ReturnsMatch()
{
var original = CreateVerdictWithFindings("CVE-2024-001", "CVE-2024-002", "CVE-2024-003");
var replayed = CreateVerdictWithFindings("CVE-2024-003", "CVE-2024-001", "CVE-2024-002");
var result = _comparer.Compare(replayed, original, VerdictComparisonOptions.Default);
result.MatchStatus.Should().Be(ReplayMatchStatus.ExactMatch);
}
[Fact]
public void Compare_ExtraFindings_DetectsAdditions()
{
var original = CreateVerdictWithFindings("CVE-2024-001");
var replayed = CreateVerdictWithFindings("CVE-2024-001", "CVE-2024-002");
var result = _comparer.Compare(replayed, original, VerdictComparisonOptions.Default);
result.MatchStatus.Should().Be(ReplayMatchStatus.Mismatch);
result.Differences.Should().ContainSingle(d => d.Field == "Finding:CVE-2024-002");
}
[Fact]
public void Compare_CalculatesCorrectConfidence()
{
var original = CreateVerdict(decision: ReplayDecision.Pass, score: 85.0m);
var replayed = CreateVerdict(decision: ReplayDecision.Fail, score: 75.0m);
var result = _comparer.Compare(replayed, original, VerdictComparisonOptions.Default);
result.DeterminismConfidence.Should().BeLessThan(1.0m);
result.DeterminismConfidence.Should().BeGreaterThanOrEqualTo(0m);
}
private static ReplayedVerdict CreateVerdict(
ReplayDecision decision = ReplayDecision.Pass,
decimal score = 85.0m) => new()
{
ArtifactDigest = "sha256:test123",
Decision = decision,
Score = score,
FindingIds = []
};
private static ReplayedVerdict CreateVerdictWithFindings(params string[] findingIds) => new()
{
ArtifactDigest = "sha256:test123",
Decision = ReplayDecision.Pass,
Score = 85.0m,
FindingIds = findingIds.ToList()
};
}

View File

@@ -0,0 +1,159 @@
using FluentAssertions;
using StellaOps.Cryptography;
using StellaOps.Policy.Snapshots;
using Xunit;
namespace StellaOps.Policy.Tests.Snapshots;
public sealed class SnapshotBuilderTests
{
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
[Fact]
public void Build_ValidInputs_CreatesManifest()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz");
var manifest = builder.Build();
manifest.SnapshotId.Should().StartWith("ksm:sha256:");
manifest.SnapshotId.Length.Should().Be("ksm:sha256:".Length + 64); // ksm:sha256: + 64 hex chars
manifest.Sources.Should().HaveCount(1);
manifest.Engine.Name.Should().Be("test");
manifest.Engine.Version.Should().Be("1.0");
manifest.Engine.Commit.Should().Be("abc123");
manifest.Policy.PolicyId.Should().Be("policy-1");
manifest.Scoring.RulesId.Should().Be("scoring-1");
}
[Fact]
public void Build_MissingEngine_Throws()
{
var builder = new SnapshotBuilder(_hasher)
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz");
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Engine*");
}
[Fact]
public void Build_MissingPolicy_Throws()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz");
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Policy*");
}
[Fact]
public void Build_MissingScoring_Throws()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz");
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Scoring*");
}
[Fact]
public void Build_NoSources_Throws()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy");
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*source*");
}
[Fact]
public void Build_MultipleSources_OrderedByName()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("z-source", "2025-12-21", "sha256:aaa")
.WithAdvisoryFeed("a-source", "2025-12-21", "sha256:bbb")
.WithAdvisoryFeed("m-source", "2025-12-21", "sha256:ccc");
var manifest = builder.Build();
manifest.Sources.Should().HaveCount(3);
manifest.Sources[0].Name.Should().Be("a-source");
manifest.Sources[1].Name.Should().Be("m-source");
manifest.Sources[2].Name.Should().Be("z-source");
}
[Fact]
public void Build_WithPlugins_IncludesPlugins()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz")
.WithPlugin("reachability", "2.0", "analyzer")
.WithPlugin("sbom", "1.5", "analyzer");
var manifest = builder.Build();
manifest.Plugins.Should().HaveCount(2);
manifest.Plugins[0].Name.Should().Be("reachability");
manifest.Plugins[1].Name.Should().Be("sbom");
}
[Fact]
public void Build_WithTrust_IncludesTrust()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz")
.WithTrust("trust-bundle", "sha256:trust123");
var manifest = builder.Build();
manifest.Trust.Should().NotBeNull();
manifest.Trust!.BundleId.Should().Be("trust-bundle");
manifest.Trust.Digest.Should().Be("sha256:trust123");
}
[Fact]
public void Build_CaptureCurrentEnvironment_SetsEnvironment()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:xxx")
.WithScoring("scoring-1", "sha256:yyy")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:zzz")
.CaptureCurrentEnvironment();
var manifest = builder.Build();
manifest.Environment.Should().NotBeNull();
manifest.Environment!.Platform.Should().NotBeNullOrEmpty();
manifest.Environment.Locale.Should().NotBeNullOrEmpty();
}
}

View File

@@ -0,0 +1,183 @@
using FluentAssertions;
using StellaOps.Cryptography;
using StellaOps.Policy.Snapshots;
using Xunit;
namespace StellaOps.Policy.Tests.Snapshots;
public sealed class SnapshotIdGeneratorTests
{
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
private readonly SnapshotIdGenerator _generator;
public SnapshotIdGeneratorTests()
{
_generator = new SnapshotIdGenerator(_hasher);
}
[Fact]
public void GenerateId_DeterministicForSameContent()
{
var manifest = CreateTestManifest();
var id1 = _generator.GenerateId(manifest);
var id2 = _generator.GenerateId(manifest);
id1.Should().Be(id2);
}
[Fact]
public void GenerateId_DifferentForDifferentContent()
{
var now = DateTimeOffset.UtcNow;
var manifest1 = CreateTestManifest() with { CreatedAt = now };
var manifest2 = CreateTestManifest() with { CreatedAt = now.AddSeconds(1) };
var id1 = _generator.GenerateId(manifest1);
var id2 = _generator.GenerateId(manifest2);
id1.Should().NotBe(id2);
}
[Fact]
public void GenerateId_StartsWithCorrectPrefix()
{
var manifest = CreateTestManifest();
var id = _generator.GenerateId(manifest);
id.Should().StartWith("ksm:sha256:");
}
[Fact]
public void GenerateId_HasCorrectLength()
{
var manifest = CreateTestManifest();
var id = _generator.GenerateId(manifest);
// ksm:sha256: (11 chars) + 64 hex chars = 75 chars
id.Length.Should().Be(75);
}
[Fact]
public void ValidateId_ValidManifest_ReturnsTrue()
{
var builder = new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc")
.WithPolicy("p", "sha256:x")
.WithScoring("s", "sha256:y")
.WithAdvisoryFeed("nvd", "2025", "sha256:z");
var manifest = builder.Build();
_generator.ValidateId(manifest).Should().BeTrue();
}
[Fact]
public void ValidateId_TamperedManifest_ReturnsFalse()
{
var manifest = CreateTestManifest();
var tampered = manifest with { Policy = manifest.Policy with { Digest = "sha256:tampered" } };
_generator.ValidateId(tampered).Should().BeFalse();
}
[Fact]
public void ValidateId_ModifiedSnapshotId_ReturnsFalse()
{
var manifest = CreateTestManifest();
var tampered = manifest with { SnapshotId = "ksm:sha256:0000000000000000000000000000000000000000000000000000000000000000" };
_generator.ValidateId(tampered).Should().BeFalse();
}
[Fact]
public void ParseId_ValidId_ReturnsComponents()
{
var manifest = CreateTestManifest();
var id = _generator.GenerateId(manifest);
var result = _generator.ParseId(id);
result.Should().NotBeNull();
result!.Algorithm.Should().Be("sha256");
result.Hash.Should().HaveLength(64);
}
[Fact]
public void ParseId_InvalidPrefix_ReturnsNull()
{
var result = _generator.ParseId("invalid:sha256:abc123");
result.Should().BeNull();
}
[Fact]
public void ParseId_ShortHash_ReturnsNull()
{
var result = _generator.ParseId("ksm:sha256:abc123");
result.Should().BeNull();
}
[Fact]
public void ParseId_EmptyString_ReturnsNull()
{
var result = _generator.ParseId("");
result.Should().BeNull();
}
[Fact]
public void IsValidIdFormat_ValidId_ReturnsTrue()
{
var manifest = CreateTestManifest();
var id = _generator.GenerateId(manifest);
_generator.IsValidIdFormat(id).Should().BeTrue();
}
[Fact]
public void IsValidIdFormat_InvalidId_ReturnsFalse()
{
_generator.IsValidIdFormat("invalid-id").Should().BeFalse();
}
[Fact]
public void GenerateId_ExcludesSignature()
{
var manifest = CreateTestManifest();
var signedManifest = manifest with { Signature = "some-signature" };
var id1 = _generator.GenerateId(manifest);
var id2 = _generator.GenerateId(signedManifest);
id1.Should().Be(id2);
}
private KnowledgeSnapshotManifest CreateTestManifest()
{
return new KnowledgeSnapshotManifest
{
SnapshotId = "test",
CreatedAt = new DateTimeOffset(2025, 12, 21, 0, 0, 0, TimeSpan.Zero),
Engine = new EngineInfo("test", "1.0", "abc123"),
Plugins = [],
Policy = new PolicyBundleRef("policy-1", "sha256:policy", null),
Scoring = new ScoringRulesRef("scoring-1", "sha256:scoring", null),
Trust = null,
Sources = new List<KnowledgeSourceDescriptor>
{
new KnowledgeSourceDescriptor
{
Name = "nvd",
Type = KnowledgeSourceTypes.AdvisoryFeed,
Epoch = "2025-12-21",
Digest = "sha256:nvd"
}
},
Environment = null
};
}
}

View File

@@ -0,0 +1,170 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using StellaOps.Policy.Snapshots;
using Xunit;
namespace StellaOps.Policy.Tests.Snapshots;
public sealed class SnapshotServiceTests
{
private readonly ICryptoHash _hasher = DefaultCryptoHash.CreateForTests();
private readonly SnapshotIdGenerator _idGenerator;
private readonly InMemorySnapshotStore _store;
private readonly SnapshotService _service;
public SnapshotServiceTests()
{
_idGenerator = new SnapshotIdGenerator(_hasher);
_store = new InMemorySnapshotStore();
_service = new SnapshotService(
_idGenerator,
_store,
NullLogger<SnapshotService>.Instance);
}
[Fact]
public async Task CreateSnapshot_PersistsManifest()
{
var builder = CreateBuilder();
var manifest = await _service.CreateSnapshotAsync(builder);
var retrieved = await _service.GetSnapshotAsync(manifest.SnapshotId);
retrieved.Should().NotBeNull();
retrieved!.SnapshotId.Should().Be(manifest.SnapshotId);
}
[Fact]
public async Task CreateSnapshot_GeneratesValidId()
{
var builder = CreateBuilder();
var manifest = await _service.CreateSnapshotAsync(builder);
manifest.SnapshotId.Should().StartWith("ksm:sha256:");
_idGenerator.ValidateId(manifest).Should().BeTrue();
}
[Fact]
public async Task GetSnapshot_NonExistent_ReturnsNull()
{
var result = await _service.GetSnapshotAsync("ksm:sha256:nonexistent");
result.Should().BeNull();
}
[Fact]
public async Task VerifySnapshot_ValidManifest_ReturnsSuccess()
{
var builder = CreateBuilder();
var manifest = await _service.CreateSnapshotAsync(builder);
var result = await _service.VerifySnapshotAsync(manifest);
result.IsValid.Should().BeTrue();
result.Error.Should().BeNull();
}
[Fact]
public async Task VerifySnapshot_TamperedManifest_ReturnsFail()
{
var builder = CreateBuilder();
var manifest = await _service.CreateSnapshotAsync(builder);
var tampered = manifest with { Policy = manifest.Policy with { Digest = "sha256:tampered" } };
var result = await _service.VerifySnapshotAsync(tampered);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("does not match");
}
[Fact]
public async Task ListSnapshots_ReturnsOrderedByCreatedAt()
{
var builder1 = CreateBuilder();
var manifest1 = await _service.CreateSnapshotAsync(builder1);
await Task.Delay(10); // Ensure different timestamp
var builder2 = CreateBuilder();
var manifest2 = await _service.CreateSnapshotAsync(builder2);
var list = await _service.ListSnapshotsAsync();
list.Should().HaveCount(2);
list[0].CreatedAt.Should().BeOnOrAfter(list[1].CreatedAt); // Descending order
}
[Fact]
public async Task ListSnapshots_RespectsSkipAndTake()
{
for (int i = 0; i < 5; i++)
{
await _service.CreateSnapshotAsync(CreateBuilder());
await Task.Delay(5); // Ensure different timestamps
}
var list = await _service.ListSnapshotsAsync(skip: 1, take: 2);
list.Should().HaveCount(2);
}
[Fact]
public void SealSnapshot_WithoutSigner_Throws()
{
var manifest = CreateTestManifest();
var act = async () => await _service.SealSnapshotAsync(manifest);
act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*signer*");
}
[Fact]
public async Task Store_Delete_RemovesSnapshot()
{
var builder = CreateBuilder();
var manifest = await _service.CreateSnapshotAsync(builder);
var deleted = await _store.DeleteAsync(manifest.SnapshotId);
var retrieved = await _service.GetSnapshotAsync(manifest.SnapshotId);
deleted.Should().BeTrue();
retrieved.Should().BeNull();
}
private SnapshotBuilder CreateBuilder()
{
return new SnapshotBuilder(_hasher)
.WithEngine("test", "1.0", "abc123")
.WithPolicy("policy-1", "sha256:policy")
.WithScoring("scoring-1", "sha256:scoring")
.WithAdvisoryFeed("nvd", "2025-12-21", "sha256:nvd");
}
private KnowledgeSnapshotManifest CreateTestManifest()
{
return new KnowledgeSnapshotManifest
{
SnapshotId = "ksm:sha256:test123",
CreatedAt = DateTimeOffset.UtcNow,
Engine = new EngineInfo("test", "1.0", "abc123"),
Plugins = [],
Policy = new PolicyBundleRef("policy-1", "sha256:policy", null),
Scoring = new ScoringRulesRef("scoring-1", "sha256:scoring", null),
Trust = null,
Sources = new List<KnowledgeSourceDescriptor>
{
new KnowledgeSourceDescriptor
{
Name = "nvd",
Type = KnowledgeSourceTypes.AdvisoryFeed,
Epoch = "2025-12-21",
Digest = "sha256:nvd"
}
},
Environment = null
};
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>