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

@@ -183,4 +183,158 @@ public class NodeCallGraphExtractorTests
Assert.Equal("/users/:id", ep.Route);
Assert.Equal("GET", ep.Method);
}
[Fact]
public void BabelResultParser_ParsesSinks()
{
// Arrange
var json = """
{
"module": "test",
"nodes": [
{
"id": "js:test/handler.processRequest",
"package": "test",
"name": "processRequest"
}
],
"edges": [],
"entrypoints": [],
"sinks": [
{
"caller": "js:test/handler.processRequest",
"category": "command_injection",
"method": "child_process.exec",
"site": {
"file": "handler.js",
"line": 42,
"column": 8
}
}
]
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Single(result.Sinks);
var sink = result.Sinks[0];
Assert.Equal("js:test/handler.processRequest", sink.Caller);
Assert.Equal("command_injection", sink.Category);
Assert.Equal("child_process.exec", sink.Method);
Assert.NotNull(sink.Site);
Assert.Equal("handler.js", sink.Site.File);
Assert.Equal(42, sink.Site.Line);
}
[Fact]
public void BabelResultParser_ParsesMultipleSinkCategories()
{
// Arrange
var json = """
{
"module": "vulnerable-app",
"nodes": [],
"edges": [],
"entrypoints": [],
"sinks": [
{
"caller": "js:vulnerable-app/db.query",
"category": "sql_injection",
"method": "connection.query"
},
{
"caller": "js:vulnerable-app/api.fetch",
"category": "ssrf",
"method": "fetch"
},
{
"caller": "js:vulnerable-app/file.write",
"category": "file_write",
"method": "fs.writeFileSync"
}
]
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Equal(3, result.Sinks.Count);
Assert.Contains(result.Sinks, s => s.Category == "sql_injection");
Assert.Contains(result.Sinks, s => s.Category == "ssrf");
Assert.Contains(result.Sinks, s => s.Category == "file_write");
}
[Fact]
public void BabelResultParser_ParsesEmptySinks()
{
// Arrange
var json = """
{
"module": "safe-app",
"nodes": [],
"edges": [],
"entrypoints": [],
"sinks": []
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Empty(result.Sinks);
}
[Fact]
public void BabelResultParser_ParsesMissingSinks()
{
// Arrange - sinks field omitted entirely
var json = """
{
"module": "legacy-app",
"nodes": [],
"edges": [],
"entrypoints": []
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert - should default to empty list
Assert.Empty(result.Sinks);
}
[Fact]
public void BabelResultParser_ParsesSinkWithoutSite()
{
// Arrange
var json = """
{
"module": "test",
"nodes": [],
"edges": [],
"entrypoints": [],
"sinks": [
{
"caller": "js:test/func",
"category": "deserialization",
"method": "eval"
}
]
}
""";
// Act
var result = BabelResultParser.Parse(json);
// Assert
Assert.Single(result.Sinks);
Assert.Null(result.Sinks[0].Site);
}
}

View File

@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
public class AssumptionCollectorTests
{
[Fact]
public void Record_AddsAssumption()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
AssumptionSource.StaticAnalysis,
ConfidenceLevel.High);
var result = collector.Build();
result.Assumptions.Should().HaveCount(1);
result.Assumptions[0].Key.Should().Be("-fstack-protector");
result.Assumptions[0].AssumedValue.Should().Be("enabled");
}
[Fact]
public void Record_KeepsHigherConfidence()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"unknown",
AssumptionSource.Default,
ConfidenceLevel.Low);
collector.Record(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
AssumptionSource.StaticAnalysis,
ConfidenceLevel.High);
var result = collector.Build();
result.Assumptions.Should().HaveCount(1);
result.Assumptions[0].AssumedValue.Should().Be("enabled");
result.Assumptions[0].Confidence.Should().Be(ConfidenceLevel.High);
}
[Fact]
public void RecordObservation_UpdatesExisting()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.RuntimeConfig,
"DEBUG_MODE",
"false",
AssumptionSource.Default,
ConfidenceLevel.Low);
collector.RecordObservation(
AssumptionCategory.RuntimeConfig,
"DEBUG_MODE",
"true");
var result = collector.Build();
result.Assumptions.Should().HaveCount(1);
result.Assumptions[0].AssumedValue.Should().Be("false");
result.Assumptions[0].ObservedValue.Should().Be("true");
result.Assumptions[0].Confidence.Should().Be(ConfidenceLevel.Verified);
result.Assumptions[0].IsContradicted.Should().BeTrue();
}
[Fact]
public void RecordObservation_CreatesNewWhenNotExisting()
{
var collector = new AssumptionCollector();
collector.RecordObservation(
AssumptionCategory.NetworkExposure,
"PORT_8080",
"open");
var result = collector.Build();
result.Assumptions.Should().HaveCount(1);
result.Assumptions[0].AssumedValue.Should().Be("open");
result.Assumptions[0].ObservedValue.Should().Be("open");
result.Assumptions[0].IsValidated.Should().BeTrue();
}
[Fact]
public void Build_SetsContextId()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.CompilerFlag,
"flag",
"value",
AssumptionSource.Default);
var result = collector.Build("finding-123");
result.ContextId.Should().Be("finding-123");
result.Id.Should().NotBeNullOrEmpty();
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Clear_RemovesAllAssumptions()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.CompilerFlag,
"flag1",
"value",
AssumptionSource.Default);
collector.Record(
AssumptionCategory.RuntimeConfig,
"config1",
"value",
AssumptionSource.Default);
collector.Clear();
var result = collector.Build();
result.Assumptions.Should().BeEmpty();
}
[Fact]
public void Build_GeneratesUniqueIds()
{
var collector = new AssumptionCollector();
collector.Record(
AssumptionCategory.CompilerFlag,
"flag",
"value",
AssumptionSource.Default);
var result1 = collector.Build();
collector.Clear();
collector.Record(
AssumptionCategory.CompilerFlag,
"flag",
"value",
AssumptionSource.Default);
var result2 = collector.Build();
result1.Id.Should().NotBe(result2.Id);
}
}

View File

@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
public class AssumptionSetTests
{
[Fact]
public void AssumptionSet_Empty_HasLowConfidence()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow
};
set.OverallConfidence.Should().Be(ConfidenceLevel.Low);
set.ValidatedCount.Should().Be(0);
set.ContradictedCount.Should().Be(0);
set.HasContradictions.Should().BeFalse();
}
[Fact]
public void AssumptionSet_OverallConfidence_ReturnsMinimum()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low),
new Assumption(AssumptionCategory.FeatureGate, "gate1", "value", null, AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
]
};
set.OverallConfidence.Should().Be(ConfidenceLevel.Low);
}
[Fact]
public void AssumptionSet_GetByCategory_ReturnsMatchingAssumptions()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "value", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
]
};
set.GetByCategory(AssumptionCategory.CompilerFlag).Should().HaveCount(2);
set.GetByCategory(AssumptionCategory.RuntimeConfig).Should().HaveCount(1);
set.GetByCategory(AssumptionCategory.FeatureGate).Should().BeEmpty();
}
[Fact]
public void AssumptionSet_Get_ReturnsSpecificAssumption()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "-fstack-protector", "enabled", null, AssumptionSource.StaticAnalysis, ConfidenceLevel.High)
]
};
var result = set.Get(AssumptionCategory.CompilerFlag, "-fstack-protector");
result.Should().NotBeNull();
result!.AssumedValue.Should().Be("enabled");
set.Get(AssumptionCategory.CompilerFlag, "nonexistent").Should().BeNull();
}
[Fact]
public void AssumptionSet_ValidationRatio_CalculatedCorrectly()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "enabled", "enabled", AssumptionSource.StaticAnalysis, ConfidenceLevel.Verified),
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "enabled", "disabled", AssumptionSource.StaticAnalysis, ConfidenceLevel.High),
new Assumption(AssumptionCategory.RuntimeConfig, "config1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
]
};
set.ValidatedCount.Should().Be(1);
set.ContradictedCount.Should().Be(1);
set.HasContradictions.Should().BeTrue();
set.ValidationRatio.Should().Be(0.5); // 1 validated out of 2 with observations
}
[Fact]
public void AssumptionSet_WithAssumption_AddsNew()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow
};
var newAssumption = new Assumption(
AssumptionCategory.CompilerFlag,
"new-flag",
"value",
null,
AssumptionSource.Default,
ConfidenceLevel.Low);
var updated = set.WithAssumption(newAssumption);
set.Assumptions.Should().BeEmpty();
updated.Assumptions.Should().HaveCount(1);
updated.Assumptions[0].Key.Should().Be("new-flag");
}
[Fact]
public void AssumptionSet_WithObservation_UpdatesExisting()
{
var set = new AssumptionSet
{
Id = "test-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "-fstack-protector", "enabled", null, AssumptionSource.Default, ConfidenceLevel.Low)
]
};
var updated = set.WithObservation(AssumptionCategory.CompilerFlag, "-fstack-protector", "disabled");
updated.Assumptions[0].ObservedValue.Should().Be("disabled");
updated.Assumptions[0].IsContradicted.Should().BeTrue();
}
}

View File

@@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
namespace StellaOps.Scanner.Explainability.Tests.Assumptions;
public class AssumptionTests
{
[Fact]
public void Assumption_IsValidated_ReturnsTrueWhenValuesMatch()
{
var assumption = new Assumption(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
"enabled",
AssumptionSource.StaticAnalysis,
ConfidenceLevel.High);
assumption.IsValidated.Should().BeTrue();
assumption.IsContradicted.Should().BeFalse();
}
[Fact]
public void Assumption_IsContradicted_ReturnsTrueWhenValuesDiffer()
{
var assumption = new Assumption(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
"disabled",
AssumptionSource.StaticAnalysis,
ConfidenceLevel.High);
assumption.IsValidated.Should().BeFalse();
assumption.IsContradicted.Should().BeTrue();
}
[Fact]
public void Assumption_NoObservedValue_NeitherValidatedNorContradicted()
{
var assumption = new Assumption(
AssumptionCategory.RuntimeConfig,
"DEBUG_MODE",
"false",
null,
AssumptionSource.Default,
ConfidenceLevel.Low);
assumption.IsValidated.Should().BeFalse();
assumption.IsContradicted.Should().BeFalse();
}
[Fact]
public void Assumption_CaseInsensitiveComparison()
{
var assumption = new Assumption(
AssumptionCategory.FeatureGate,
"FEATURE_ENABLED",
"TRUE",
"true",
AssumptionSource.RuntimeObservation,
ConfidenceLevel.Verified);
assumption.IsValidated.Should().BeTrue();
}
[Theory]
[InlineData(AssumptionCategory.CompilerFlag)]
[InlineData(AssumptionCategory.RuntimeConfig)]
[InlineData(AssumptionCategory.FeatureGate)]
[InlineData(AssumptionCategory.LoaderBehavior)]
[InlineData(AssumptionCategory.NetworkExposure)]
[InlineData(AssumptionCategory.ProcessPrivilege)]
[InlineData(AssumptionCategory.MemoryProtection)]
[InlineData(AssumptionCategory.SyscallAvailability)]
public void AssumptionCategory_AllValuesAreValid(AssumptionCategory category)
{
var assumption = new Assumption(
category,
"test-key",
"test-value",
null,
AssumptionSource.Default,
ConfidenceLevel.Low);
assumption.Category.Should().Be(category);
}
}

View File

@@ -0,0 +1,219 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Confidence;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Tests.Confidence;
public class EvidenceDensityScorerTests
{
private readonly EvidenceDensityScorer _scorer = new();
[Fact]
public void Calculate_EmptyFactors_ReturnsLowConfidence()
{
var factors = new EvidenceFactors { SourceCount = 0 };
var result = _scorer.Calculate(factors);
result.Score.Should().Be(0.0);
result.Level.Should().Be(ConfidenceLevel.Low);
result.ImprovementRecommendations.Should().NotBeEmpty();
}
[Fact]
public void Calculate_AllFactorsPresent_ReturnsHighConfidence()
{
var assumptions = new AssumptionSet
{
Id = "test",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag", "value", "value", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
]
};
var falsifiability = new FalsifiabilityCriteria
{
Id = "test",
FindingId = "finding",
GeneratedAt = DateTimeOffset.UtcNow,
Criteria =
[
new FalsificationCriterion(FalsificationType.PackageNotPresent, "desc", null, null, CriterionStatus.NotSatisfied)
]
};
var factors = new EvidenceFactors
{
Assumptions = assumptions,
Falsifiability = falsifiability,
HasStaticReachability = true,
HasRuntimeObservations = true,
HasSbomLineage = true,
SourceCount = 3,
HasVexAssessment = true,
HasKnownExploit = true
};
var result = _scorer.Calculate(factors);
result.Score.Should().BeGreaterThan(0.75);
result.Level.Should().Be(ConfidenceLevel.Verified);
}
[Fact]
public void Calculate_FactorBreakdown_ContainsAllFactors()
{
var factors = new EvidenceFactors
{
HasStaticReachability = true
};
var result = _scorer.Calculate(factors);
result.FactorBreakdown.Should().ContainKey("assumption_validation");
result.FactorBreakdown.Should().ContainKey("falsifiability_evaluation");
result.FactorBreakdown.Should().ContainKey("static_reachability");
result.FactorBreakdown.Should().ContainKey("runtime_observations");
result.FactorBreakdown.Should().ContainKey("sbom_lineage");
result.FactorBreakdown.Should().ContainKey("multiple_sources");
result.FactorBreakdown.Should().ContainKey("vex_assessment");
result.FactorBreakdown.Should().ContainKey("known_exploit");
}
[Fact]
public void Calculate_StaticReachabilityOnly_AddsThatFactor()
{
var factors = new EvidenceFactors
{
HasStaticReachability = true
};
var result = _scorer.Calculate(factors);
result.FactorBreakdown["static_reachability"].Should().BeGreaterThan(0);
result.Score.Should().BeGreaterThan(0);
}
[Fact]
public void Calculate_MultipleSourcesScalesCorrectly()
{
var factors1 = new EvidenceFactors { SourceCount = 1 };
var factors2 = new EvidenceFactors { SourceCount = 2 };
var factors3 = new EvidenceFactors { SourceCount = 3 };
var factors4 = new EvidenceFactors { SourceCount = 10 }; // Capped at 3
var result1 = _scorer.Calculate(factors1);
var result2 = _scorer.Calculate(factors2);
var result3 = _scorer.Calculate(factors3);
var result4 = _scorer.Calculate(factors4);
result2.FactorBreakdown["multiple_sources"].Should().BeGreaterThan(result1.FactorBreakdown["multiple_sources"]);
result3.FactorBreakdown["multiple_sources"].Should().BeGreaterThan(result2.FactorBreakdown["multiple_sources"]);
result4.FactorBreakdown["multiple_sources"].Should().Be(result3.FactorBreakdown["multiple_sources"]); // Capped
}
[Fact]
public void Calculate_AssumptionValidationRatio_AffectsScore()
{
var halfValidated = new AssumptionSet
{
Id = "test",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "a", "a", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified),
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "b", "c", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
]
};
var fullyValidated = new AssumptionSet
{
Id = "test",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "a", "a", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified),
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "b", "b", AssumptionSource.RuntimeObservation, ConfidenceLevel.Verified)
]
};
var factors1 = new EvidenceFactors { Assumptions = halfValidated };
var factors2 = new EvidenceFactors { Assumptions = fullyValidated };
var result1 = _scorer.Calculate(factors1);
var result2 = _scorer.Calculate(factors2);
result2.FactorBreakdown["assumption_validation"].Should().BeGreaterThan(result1.FactorBreakdown["assumption_validation"]);
}
[Fact]
public void Calculate_Explanation_ReflectsLevel()
{
var lowFactors = new EvidenceFactors();
var highFactors = new EvidenceFactors
{
HasStaticReachability = true,
HasRuntimeObservations = true,
HasVexAssessment = true,
SourceCount = 3
};
var lowResult = _scorer.Calculate(lowFactors);
var highResult = _scorer.Calculate(highFactors);
lowResult.Explanation.Should().Contain("Low confidence");
highResult.Explanation.Should().ContainAny("High confidence", "Very high confidence");
}
[Fact]
public void Calculate_Recommendations_SuggestMissingEvidence()
{
var factors = new EvidenceFactors
{
HasStaticReachability = true
// Missing: runtime, sbom, vex, assumptions, etc.
};
var result = _scorer.Calculate(factors);
result.ImprovementRecommendations.Should().Contain(r => r.Contains("runtime"));
result.ImprovementRecommendations.Should().Contain(r => r.Contains("VEX") || r.Contains("vendor"));
}
[Theory]
[InlineData(0.0, ConfidenceLevel.Low)]
[InlineData(0.24, ConfidenceLevel.Low)]
[InlineData(0.25, ConfidenceLevel.Medium)]
[InlineData(0.49, ConfidenceLevel.Medium)]
[InlineData(0.50, ConfidenceLevel.High)]
[InlineData(0.74, ConfidenceLevel.High)]
[InlineData(0.75, ConfidenceLevel.Verified)]
[InlineData(1.0, ConfidenceLevel.Verified)]
public void ScoreToLevel_MapsCorrectly(double score, ConfidenceLevel expectedLevel)
{
// We can't directly test the private method, but we can verify through integration
// by checking that results with scores in certain ranges get the expected levels
var result = new EvidenceDensityScore
{
Score = score,
Level = score switch
{
>= 0.75 => ConfidenceLevel.Verified,
>= 0.50 => ConfidenceLevel.High,
>= 0.25 => ConfidenceLevel.Medium,
_ => ConfidenceLevel.Low
},
FactorBreakdown = new Dictionary<string, double>(),
Explanation = "test",
ImprovementRecommendations = []
};
result.Level.Should().Be(expectedLevel);
}
}

View File

@@ -0,0 +1,290 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Text.Json;
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Confidence;
using StellaOps.Scanner.Explainability.Dsse;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Tests.Dsse;
public class ExplainabilityPredicateSerializerTests
{
private readonly ExplainabilityPredicateSerializer _serializer = new();
[Fact]
public void ToPredicate_MinimalReport_CreatesValidPredicate()
{
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0"
};
var predicate = _serializer.ToPredicate(report);
predicate.FindingId.Should().Be("finding-123");
predicate.VulnerabilityId.Should().Be("CVE-2024-1234");
predicate.PackageName.Should().Be("test-pkg");
predicate.PackageVersion.Should().Be("1.0.0");
predicate.EngineVersion.Should().Be("1.0.0");
predicate.Assumptions.Should().BeNull();
predicate.Falsifiability.Should().BeNull();
predicate.ConfidenceScore.Should().BeNull();
}
[Fact]
public void ToPredicate_WithAssumptions_SerializesCorrectly()
{
var assumptions = new AssumptionSet
{
Id = "assumptions-123",
ContextId = "finding-123",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
"enabled",
AssumptionSource.RuntimeObservation,
ConfidenceLevel.Verified)
]
};
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0",
Assumptions = assumptions
};
var predicate = _serializer.ToPredicate(report);
predicate.Assumptions.Should().NotBeNull();
predicate.Assumptions!.Id.Should().Be("assumptions-123");
predicate.Assumptions.Assumptions.Should().HaveCount(1);
predicate.Assumptions.Assumptions[0].Category.Should().Be("CompilerFlag");
predicate.Assumptions.Assumptions[0].Key.Should().Be("-fstack-protector");
predicate.Assumptions.Assumptions[0].Source.Should().Be("RuntimeObservation");
predicate.Assumptions.Assumptions[0].Confidence.Should().Be("Verified");
}
[Fact]
public void ToPredicate_WithFalsifiability_SerializesCorrectly()
{
var falsifiability = new FalsifiabilityCriteria
{
Id = "falsifiability-123",
FindingId = "finding-123",
GeneratedAt = DateTimeOffset.UtcNow,
Status = FalsifiabilityStatus.Falsified,
Summary = "Finding falsified",
Criteria =
[
new FalsificationCriterion(
FalsificationType.CodeUnreachable,
"Code path is not reachable",
"reachability.check()",
"Static analysis confirmed",
CriterionStatus.Satisfied)
]
};
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0",
Falsifiability = falsifiability
};
var predicate = _serializer.ToPredicate(report);
predicate.Falsifiability.Should().NotBeNull();
predicate.Falsifiability!.Id.Should().Be("falsifiability-123");
predicate.Falsifiability.Status.Should().Be("Falsified");
predicate.Falsifiability.Criteria.Should().HaveCount(1);
predicate.Falsifiability.Criteria[0].Type.Should().Be("CodeUnreachable");
predicate.Falsifiability.Criteria[0].Status.Should().Be("Satisfied");
}
[Fact]
public void ToPredicate_WithConfidenceScore_SerializesCorrectly()
{
var score = new EvidenceDensityScore
{
Score = 0.75,
Level = ConfidenceLevel.High,
FactorBreakdown = new Dictionary<string, double>
{
["static_reachability"] = 0.15,
["runtime_observations"] = 0.20
},
Explanation = "High confidence based on evidence",
ImprovementRecommendations = ["Add VEX assessment"]
};
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0",
ConfidenceScore = score
};
var predicate = _serializer.ToPredicate(report);
predicate.ConfidenceScore.Should().NotBeNull();
predicate.ConfidenceScore!.Score.Should().Be(0.75);
predicate.ConfidenceScore.Level.Should().Be("High");
predicate.ConfidenceScore.FactorBreakdown.Should().ContainKey("static_reachability");
predicate.ConfidenceScore.ImprovementRecommendations.Should().Contain("Add VEX assessment");
}
[Fact]
public void ToPredicate_WithRecommendedActions_SerializesCorrectly()
{
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0",
RecommendedActions =
[
new RecommendedAction(1, "Update package", "Fix available", EffortLevel.Low),
new RecommendedAction(2, "Review code", "Verify impact", EffortLevel.Medium)
]
};
var predicate = _serializer.ToPredicate(report);
predicate.RecommendedActions.Should().HaveCount(2);
predicate.RecommendedActions![0].Priority.Should().Be(1);
predicate.RecommendedActions[0].Action.Should().Be("Update package");
predicate.RecommendedActions[0].Effort.Should().Be("Low");
predicate.RecommendedActions[1].Effort.Should().Be("Medium");
}
[Fact]
public void Serialize_ProducesValidJson()
{
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero),
EngineVersion = "1.0.0"
};
var bytes = _serializer.Serialize(report);
var json = System.Text.Encoding.UTF8.GetString(bytes);
json.Should().Contain("\"findingId\":\"finding-123\"");
json.Should().Contain("\"vulnerabilityId\":\"CVE-2024-1234\"");
json.Should().Contain("\"packageName\":\"test-pkg\"");
// Verify it's valid JSON
var action = () => JsonDocument.Parse(json);
action.Should().NotThrow();
}
[Fact]
public void Serialize_UsesCorrectCasing()
{
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0",
ConfidenceScore = new EvidenceDensityScore
{
Score = 0.5,
Level = ConfidenceLevel.Medium,
FactorBreakdown = new Dictionary<string, double>(),
Explanation = "test",
ImprovementRecommendations = []
}
};
var bytes = _serializer.Serialize(report);
var json = System.Text.Encoding.UTF8.GetString(bytes);
// Should use camelCase
json.Should().Contain("findingId");
json.Should().NotContain("FindingId");
json.Should().Contain("confidenceScore");
json.Should().NotContain("ConfidenceScore");
}
[Fact]
public void PredicateType_ReturnsCorrectUri()
{
IExplainabilityPredicateSerializer.PredicateType.Should().Be(
"https://stella-ops.org/predicates/finding-explainability/v2");
}
[Fact]
public void Serialize_OmitsNullValues()
{
var report = new RiskReport
{
Id = "report-123",
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "test-pkg",
PackageVersion = "1.0.0",
Explanation = "Test explanation",
GeneratedAt = DateTimeOffset.UtcNow,
EngineVersion = "1.0.0"
// Assumptions, Falsifiability, ConfidenceScore are null
};
var bytes = _serializer.Serialize(report);
var json = System.Text.Encoding.UTF8.GetString(bytes);
// Null values should be omitted
json.Should().NotContain("assumptions");
json.Should().NotContain("falsifiability");
json.Should().NotContain("confidenceScore");
}
}

View File

@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Tests.Falsifiability;
public class FalsifiabilityCriteriaTests
{
[Fact]
public void FalsifiabilityCriteria_DefaultState_HasEmptyCriteria()
{
var criteria = new FalsifiabilityCriteria
{
Id = "test-id",
FindingId = "finding-123",
GeneratedAt = DateTimeOffset.UtcNow
};
criteria.Criteria.Should().BeEmpty();
criteria.Status.Should().Be(FalsifiabilityStatus.Unknown);
}
[Fact]
public void FalsificationCriterion_StoresAllProperties()
{
var criterion = new FalsificationCriterion(
FalsificationType.CodeUnreachable,
"Code is not reachable",
"reachability.isReachable() == false",
"Static analysis confirms unreachable",
CriterionStatus.Satisfied);
criterion.Type.Should().Be(FalsificationType.CodeUnreachable);
criterion.Description.Should().Be("Code is not reachable");
criterion.CheckExpression.Should().Be("reachability.isReachable() == false");
criterion.Evidence.Should().Be("Static analysis confirms unreachable");
criterion.Status.Should().Be(CriterionStatus.Satisfied);
}
[Theory]
[InlineData(FalsificationType.PackageNotPresent)]
[InlineData(FalsificationType.VersionMismatch)]
[InlineData(FalsificationType.CodeUnreachable)]
[InlineData(FalsificationType.FeatureDisabled)]
[InlineData(FalsificationType.MitigationPresent)]
[InlineData(FalsificationType.NoNetworkExposure)]
[InlineData(FalsificationType.InsufficientPrivileges)]
[InlineData(FalsificationType.PatchApplied)]
[InlineData(FalsificationType.ConfigurationPrevents)]
[InlineData(FalsificationType.RuntimePrevents)]
public void FalsificationType_AllValuesAreValid(FalsificationType type)
{
var criterion = new FalsificationCriterion(
type,
"Test description",
null,
null,
CriterionStatus.Pending);
criterion.Type.Should().Be(type);
}
[Theory]
[InlineData(CriterionStatus.Pending)]
[InlineData(CriterionStatus.Satisfied)]
[InlineData(CriterionStatus.NotSatisfied)]
[InlineData(CriterionStatus.Inconclusive)]
public void CriterionStatus_AllValuesAreValid(CriterionStatus status)
{
var criterion = new FalsificationCriterion(
FalsificationType.PackageNotPresent,
"Test",
null,
null,
status);
criterion.Status.Should().Be(status);
}
[Theory]
[InlineData(FalsifiabilityStatus.Unknown)]
[InlineData(FalsifiabilityStatus.Falsified)]
[InlineData(FalsifiabilityStatus.NotFalsified)]
[InlineData(FalsifiabilityStatus.PartiallyEvaluated)]
public void FalsifiabilityStatus_AllValuesAreValid(FalsifiabilityStatus status)
{
var criteria = new FalsifiabilityCriteria
{
Id = "test",
FindingId = "finding",
Status = status,
GeneratedAt = DateTimeOffset.UtcNow
};
criteria.Status.Should().Be(status);
}
}

View File

@@ -0,0 +1,194 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Tests.Falsifiability;
public class FalsifiabilityGeneratorTests
{
private readonly FalsifiabilityGenerator _generator = new(NullLogger<FalsifiabilityGenerator>.Instance);
[Fact]
public void Generate_MinimalInput_CreatesBasicCriteria()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0"
};
var result = _generator.Generate(input);
result.FindingId.Should().Be("finding-123");
result.Criteria.Should().ContainSingle(c => c.Type == FalsificationType.PackageNotPresent);
result.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Generate_WithVulnerableRange_AddsVersionMismatchCriterion()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
VulnerableRange = ">=1.0.0 <2.0.0"
};
var result = _generator.Generate(input);
result.Criteria.Should().Contain(c => c.Type == FalsificationType.VersionMismatch);
}
[Fact]
public void Generate_WithFixedVersion_AddsPatchAppliedCriterion()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
FixedVersion = "1.0.1"
};
var result = _generator.Generate(input);
result.Criteria.Should().Contain(c => c.Type == FalsificationType.PatchApplied);
}
[Fact]
public void Generate_WithReachabilityData_UnreachableCode_CreatesSatisfiedCriterion()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
HasReachabilityData = true,
IsReachable = false
};
var result = _generator.Generate(input);
var reachabilityCriterion = result.Criteria.FirstOrDefault(c => c.Type == FalsificationType.CodeUnreachable);
reachabilityCriterion.Should().NotBeNull();
reachabilityCriterion!.Status.Should().Be(CriterionStatus.Satisfied);
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
}
[Fact]
public void Generate_WithReachabilityData_ReachableCode_CreatesNotSatisfiedCriterion()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
HasReachabilityData = true,
IsReachable = true
};
var result = _generator.Generate(input);
var reachabilityCriterion = result.Criteria.FirstOrDefault(c => c.Type == FalsificationType.CodeUnreachable);
reachabilityCriterion.Should().NotBeNull();
reachabilityCriterion!.Status.Should().Be(CriterionStatus.NotSatisfied);
}
[Fact]
public void Generate_WithMitigations_CreatesSatisfiedCriteria()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
Mitigations = ["ASLR enabled", "Stack canaries"]
};
var result = _generator.Generate(input);
var mitigationCriteria = result.Criteria.Where(c => c.Type == FalsificationType.MitigationPresent).ToList();
mitigationCriteria.Should().HaveCount(2);
mitigationCriteria.Should().OnlyContain(c => c.Status == CriterionStatus.Satisfied);
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
}
[Fact]
public void Generate_WithContradictedAssumptions_AddsCriteria()
{
var assumptions = new AssumptionSet
{
Id = "assumptions-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(
AssumptionCategory.NetworkExposure,
"port-443",
"open",
"closed",
AssumptionSource.RuntimeObservation,
ConfidenceLevel.Verified)
]
};
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0",
Assumptions = assumptions
};
var result = _generator.Generate(input);
result.Criteria.Should().Contain(c => c.Type == FalsificationType.NoNetworkExposure);
result.Status.Should().Be(FalsifiabilityStatus.Falsified);
}
[Fact]
public void Generate_NoCriteriaSatisfied_ReturnsPartiallyEvaluated()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0"
};
var result = _generator.Generate(input);
// Only pending criteria (package presence check)
result.Status.Should().Be(FalsifiabilityStatus.PartiallyEvaluated);
}
[Fact]
public void Generate_Summary_IncludesFindingId()
{
var input = new FalsifiabilityInput
{
FindingId = "finding-xyz",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
InstalledVersion = "1.0.0"
};
var result = _generator.Generate(input);
result.Summary.Should().Contain("finding-xyz");
}
}

View File

@@ -0,0 +1,269 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Explainability.Confidence;
using StellaOps.Scanner.Explainability.Falsifiability;
namespace StellaOps.Scanner.Explainability.Tests;
public class RiskReportTests
{
private readonly RiskReportGenerator _generator;
public RiskReportTests()
{
_generator = new RiskReportGenerator(new EvidenceDensityScorer());
}
[Fact]
public void Generate_MinimalInput_CreatesReport()
{
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0"
};
var result = _generator.Generate(input);
result.FindingId.Should().Be("finding-123");
result.VulnerabilityId.Should().Be("CVE-2024-1234");
result.PackageName.Should().Be("vulnerable-pkg");
result.PackageVersion.Should().Be("1.0.0");
result.Explanation.Should().Contain("CVE-2024-1234");
result.EngineVersion.Should().Be("1.0.0");
result.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Generate_WithSeverity_IncludesInExplanation()
{
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
Severity = "CRITICAL"
};
var result = _generator.Generate(input);
result.Explanation.Should().Contain("CRITICAL");
}
[Fact]
public void Generate_WithFixedVersion_RecommendsUpdate()
{
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
FixedVersion = "1.0.1"
};
var result = _generator.Generate(input);
result.RecommendedActions.Should().Contain(a =>
a.Action.Contains("Update") && a.Action.Contains("1.0.1"));
}
[Fact]
public void Generate_WithoutFixedVersion_RecommendsMonitoring()
{
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0"
};
var result = _generator.Generate(input);
result.RecommendedActions.Should().Contain(a =>
a.Action.Contains("Monitor") || a.Action.Contains("compensating"));
}
[Fact]
public void Generate_WithEvidenceFactors_CalculatesConfidence()
{
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
EvidenceFactors = new EvidenceFactors
{
HasStaticReachability = true,
HasRuntimeObservations = true
}
};
var result = _generator.Generate(input);
result.ConfidenceScore.Should().NotBeNull();
result.ConfidenceScore!.Score.Should().BeGreaterThan(0);
}
[Fact]
public void Generate_WithAssumptions_IncludesInReport()
{
var assumptions = new AssumptionSet
{
Id = "assumptions-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(
AssumptionCategory.CompilerFlag,
"-fstack-protector",
"enabled",
"enabled",
AssumptionSource.StaticAnalysis,
ConfidenceLevel.High)
]
};
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
Assumptions = assumptions
};
var result = _generator.Generate(input);
result.Assumptions.Should().BeSameAs(assumptions);
result.DetailedNarrative.Should().Contain("Assumptions");
}
[Fact]
public void Generate_WithFalsifiability_IncludesInReport()
{
var falsifiability = new FalsifiabilityCriteria
{
Id = "falsifiability-id",
FindingId = "finding-123",
Status = FalsifiabilityStatus.Falsified,
Summary = "Finding has been falsified",
GeneratedAt = DateTimeOffset.UtcNow,
Criteria =
[
new FalsificationCriterion(
FalsificationType.CodeUnreachable,
"Code is unreachable",
null,
null,
CriterionStatus.Satisfied)
]
};
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
Falsifiability = falsifiability
};
var result = _generator.Generate(input);
result.Falsifiability.Should().BeSameAs(falsifiability);
result.Explanation.Should().Contain("falsified");
}
[Fact]
public void Generate_WithUnvalidatedAssumptions_RecommendsValidation()
{
var assumptions = new AssumptionSet
{
Id = "assumptions-id",
CreatedAt = DateTimeOffset.UtcNow,
Assumptions =
[
new Assumption(AssumptionCategory.CompilerFlag, "flag1", "value", null, AssumptionSource.Default, ConfidenceLevel.Low),
new Assumption(AssumptionCategory.CompilerFlag, "flag2", "value", null, AssumptionSource.Default, ConfidenceLevel.Low)
]
};
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
Assumptions = assumptions
};
var result = _generator.Generate(input);
result.RecommendedActions.Should().Contain(a =>
a.Action.Contains("Validate") || a.Action.Contains("assumption"));
}
[Fact]
public void Generate_WithPartiallyEvaluatedFalsifiability_RecommendsCompletion()
{
var falsifiability = new FalsifiabilityCriteria
{
Id = "falsifiability-id",
FindingId = "finding-123",
Status = FalsifiabilityStatus.PartiallyEvaluated,
GeneratedAt = DateTimeOffset.UtcNow,
Criteria =
[
new FalsificationCriterion(FalsificationType.CodeUnreachable, "desc", null, null, CriterionStatus.Pending)
]
};
var input = new RiskReportInput
{
FindingId = "finding-123",
VulnerabilityId = "CVE-2024-1234",
PackageName = "vulnerable-pkg",
PackageVersion = "1.0.0",
Falsifiability = falsifiability
};
var result = _generator.Generate(input);
result.RecommendedActions.Should().Contain(a =>
a.Action.Contains("falsifiability") || a.Action.Contains("evaluation"));
}
[Fact]
public void RecommendedAction_HasRequiredProperties()
{
var action = new RecommendedAction(
Priority: 1,
Action: "Update package",
Rationale: "Fix is available",
Effort: EffortLevel.Low);
action.Priority.Should().Be(1);
action.Action.Should().Be("Update package");
action.Rationale.Should().Be("Fix is available");
action.Effort.Should().Be(EffortLevel.Low);
}
[Theory]
[InlineData(EffortLevel.Low)]
[InlineData(EffortLevel.Medium)]
[InlineData(EffortLevel.High)]
public void EffortLevel_AllValuesAreValid(EffortLevel effort)
{
var action = new RecommendedAction(1, "Test", "Test", effort);
action.Effort.Should().Be(effort);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Explainability\StellaOps.Scanner.Explainability.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,397 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_4400_0001_0001_signed_delta_verdict
// Task: DELTA-008 - Integration tests for delta verdict attestation
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.ProofChain.Predicates;
using StellaOps.DeltaVerdict.Models;
using StellaOps.DeltaVerdict.Oci;
using StellaOps.DeltaVerdict.Serialization;
using StellaOps.DeltaVerdict.Signing;
using StellaOps.Scanner.SmartDiff.Attestation;
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiffTests.Integration;
/// <summary>
/// Integration tests for delta verdict attestation flow.
/// Sprint: SPRINT_4400_0001_0001 - Signed Delta Verdict Attestation.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Sprint", "4400.1")]
public sealed class DeltaVerdictAttestationTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
#region End-to-End Flow Tests
[Fact(DisplayName = "Delta verdict build and sign produces valid attestation")]
public async Task BuildAndSign_ProducesValidAttestation()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var signer = new DeltaSigningService();
var request = CreateBuildRequest();
// Act - Build statement
var statement = builder.BuildStatement(request);
// Assert - Statement structure
statement.Should().NotBeNull();
statement.PredicateType.Should().Be("delta-verdict.stella/v1");
statement.Subject.Should().HaveCount(2);
statement.Predicate.Should().NotBeNull();
statement.Predicate.HasMaterialChange.Should().BeTrue();
// Act - Sign
var delta = CreateDeltaVerdictFromStatement(statement);
var signedDelta = await signer.SignAsync(delta, new SigningOptions
{
KeyId = "test-key",
PayloadType = "application/vnd.stellaops.delta-verdict+json",
SecretBase64 = Convert.ToBase64String("test-secret-key-32bytes!"u8.ToArray()),
Algorithm = SigningAlgorithm.HmacSha256
}, CancellationToken.None);
// Assert - Signing
signedDelta.Envelope.Should().NotBeNull();
signedDelta.Envelope.Signatures.Should().NotBeEmpty();
signedDelta.Envelope.Signatures[0].KeyId.Should().Be("test-key");
}
[Fact(DisplayName = "Signed delta can be verified")]
public async Task SignedDelta_CanBeVerified()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var signer = new DeltaSigningService();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
var secret = Convert.ToBase64String("verification-secret-key-32bytes!"u8.ToArray());
// Act - Sign
var signedDelta = await signer.SignAsync(delta, new SigningOptions
{
KeyId = "verification-key",
PayloadType = "application/vnd.stellaops.delta-verdict+json",
SecretBase64 = secret,
Algorithm = SigningAlgorithm.HmacSha256
}, CancellationToken.None);
// Act - Verify
var verifyResult = await signer.VerifyAsync(signedDelta, new VerificationOptions
{
KeyId = "verification-key",
SecretBase64 = secret,
Algorithm = SigningAlgorithm.HmacSha256
}, CancellationToken.None);
// Assert
verifyResult.IsValid.Should().BeTrue();
verifyResult.Error.Should().BeNullOrEmpty();
}
[Fact(DisplayName = "Verification fails with wrong key")]
public async Task Verification_FailsWithWrongKey()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var signer = new DeltaSigningService();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
// Act - Sign with one key
var signedDelta = await signer.SignAsync(delta, new SigningOptions
{
KeyId = "signing-key",
PayloadType = "application/vnd.stellaops.delta-verdict+json",
SecretBase64 = Convert.ToBase64String("correct-secret-key-32bytes!"u8.ToArray()),
Algorithm = SigningAlgorithm.HmacSha256
}, CancellationToken.None);
// Act - Verify with different key
var verifyResult = await signer.VerifyAsync(signedDelta, new VerificationOptions
{
KeyId = "signing-key",
SecretBase64 = Convert.ToBase64String("wrong-secret-key-32bytes!!"u8.ToArray()),
Algorithm = SigningAlgorithm.HmacSha256
}, CancellationToken.None);
// Assert
verifyResult.IsValid.Should().BeFalse();
}
#endregion
#region OCI Attachment Tests
[Fact(DisplayName = "OCI attachment can be created from delta verdict")]
public void OciAttachment_CanBeCreatedFromDeltaVerdict()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var attacher = new DeltaOciAttacher();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
// Act
var attachment = attacher.CreateAttachment(delta, "registry.example.com/repo@sha256:target123");
// Assert
attachment.Should().NotBeNull();
attachment.ArtifactReference.Should().Be("registry.example.com/repo@sha256:target123");
attachment.MediaType.Should().Be("application/vnd.stellaops.delta-verdict+json");
attachment.Payload.Should().NotBeEmpty();
attachment.Annotations.Should().ContainKey("org.stellaops.delta.digest");
}
[Fact(DisplayName = "OCI attachment includes before and after digests")]
public void OciAttachment_IncludesBeforeAndAfterDigests()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var attacher = new DeltaOciAttacher();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
// Act
var attachment = attacher.CreateAttachment(delta, "registry.example.com/repo@sha256:target123");
// Assert
attachment.Annotations.Should().ContainKey("org.stellaops.delta.before");
attachment.Annotations.Should().ContainKey("org.stellaops.delta.after");
}
#endregion
#region Serialization Round-Trip Tests
[Fact(DisplayName = "Delta verdict serializes and deserializes correctly")]
public void DeltaVerdict_RoundTrip_PreservesData()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
// Act
var json = DeltaVerdictSerializer.Serialize(delta);
var deserialized = DeltaVerdictSerializer.Deserialize(json);
// Assert
deserialized.Should().NotBeNull();
deserialized.BeforeDigest.Should().Be(delta.BeforeDigest);
deserialized.AfterDigest.Should().Be(delta.AfterDigest);
deserialized.HasMaterialChange.Should().Be(delta.HasMaterialChange);
deserialized.PriorityScore.Should().Be(delta.PriorityScore);
}
[Fact(DisplayName = "Serialization is deterministic")]
public void Serialization_IsDeterministic()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var request = CreateBuildRequest();
var statement = builder.BuildStatement(request);
var delta = CreateDeltaVerdictFromStatement(statement);
// Act
var json1 = DeltaVerdictSerializer.Serialize(delta);
var json2 = DeltaVerdictSerializer.Serialize(delta);
// Assert
json1.Should().Be(json2, "Serialization must be deterministic");
}
#endregion
#region Predicate Tests
[Fact(DisplayName = "Predicate includes all material changes")]
public void Predicate_IncludesAllMaterialChanges()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var request = CreateBuildRequestWithMultipleChanges();
// Act
var statement = builder.BuildStatement(request);
// Assert
statement.Predicate.Changes.Should().HaveCount(3);
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R1");
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R2");
statement.Predicate.Changes.Should().Contain(c => c.Rule == "R3");
}
[Fact(DisplayName = "Priority score is sum of individual scores")]
public void PriorityScore_IsSumOfIndividualScores()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var request = CreateBuildRequest();
// Act
var statement = builder.BuildStatement(request);
// Assert - Single change with score 100
statement.Predicate.PriorityScore.Should().Be(100);
}
[Fact(DisplayName = "Statement includes proof spine references")]
public void Statement_IncludesProofSpineReferences()
{
// Arrange
var builder = new DeltaVerdictBuilder();
var request = CreateBuildRequest();
// Act
var statement = builder.BuildStatement(request);
// Assert
statement.Predicate.BeforeProofSpineDigest.Should().Be("sha256:spine-before");
statement.Predicate.AfterProofSpineDigest.Should().Be("sha256:spine-after");
}
#endregion
#region Helper Methods
private static DeltaVerdictBuildRequest CreateBuildRequest()
{
var changes = new[]
{
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0001", "pkg:npm/lodash@4.17.20"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R1_ReachabilityFlip,
ChangeType: MaterialChangeType.ReachabilityFlip,
Direction: RiskDirection.Increased,
Reason: "Reachability changed from false to true",
PreviousValue: "false",
CurrentValue: "true",
Weight: 1.0)),
PriorityScore: 100,
PreviousStateHash: "sha256:prev-state",
CurrentStateHash: "sha256:curr-state")
};
return new DeltaVerdictBuildRequest
{
BeforeRevisionId = "rev-baseline",
AfterRevisionId = "rev-current",
BeforeImageDigest = "sha256:before123",
AfterImageDigest = "sha256:after456",
Changes = changes,
ComparedAt = new DateTimeOffset(2025, 12, 22, 12, 0, 0, TimeSpan.Zero),
BeforeProofSpine = new AttestationReference { Digest = "sha256:spine-before" },
AfterProofSpine = new AttestationReference { Digest = "sha256:spine-after" }
};
}
private static DeltaVerdictBuildRequest CreateBuildRequestWithMultipleChanges()
{
var changes = new[]
{
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0001", "pkg:npm/a@1.0.0"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R1_ReachabilityFlip,
ChangeType: MaterialChangeType.ReachabilityFlip,
Direction: RiskDirection.Increased,
Reason: "Reachability flip",
PreviousValue: "false",
CurrentValue: "true",
Weight: 1.0)),
PriorityScore: 100,
PreviousStateHash: "sha256:prev1",
CurrentStateHash: "sha256:curr1"),
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0002", "pkg:npm/b@1.0.0"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R2_VexFlip,
ChangeType: MaterialChangeType.VexFlip,
Direction: RiskDirection.Decreased,
Reason: "VEX status changed",
PreviousValue: "affected",
CurrentValue: "not_affected",
Weight: 0.8)),
PriorityScore: 50,
PreviousStateHash: "sha256:prev2",
CurrentStateHash: "sha256:curr2"),
new MaterialRiskChangeResult(
FindingKey: new FindingKey("CVE-2025-0003", "pkg:npm/c@1.0.0"),
HasMaterialChange: true,
Changes: ImmutableArray.Create(new DetectedChange(
Rule: DetectionRule.R3_SeverityEscalation,
ChangeType: MaterialChangeType.SeverityChange,
Direction: RiskDirection.Increased,
Reason: "Severity escalated",
PreviousValue: "medium",
CurrentValue: "critical",
Weight: 1.0)),
PriorityScore: 200,
PreviousStateHash: "sha256:prev3",
CurrentStateHash: "sha256:curr3")
};
return new DeltaVerdictBuildRequest
{
BeforeRevisionId = "rev-baseline",
AfterRevisionId = "rev-current",
BeforeImageDigest = "sha256:before123",
AfterImageDigest = "sha256:after456",
Changes = changes,
ComparedAt = new DateTimeOffset(2025, 12, 22, 12, 0, 0, TimeSpan.Zero),
BeforeProofSpine = new AttestationReference { Digest = "sha256:spine-before" },
AfterProofSpine = new AttestationReference { Digest = "sha256:spine-after" }
};
}
private static DeltaVerdict CreateDeltaVerdictFromStatement(DeltaVerdictStatement statement)
{
return new DeltaVerdict
{
BeforeDigest = statement.Subject[0].Digest.Values.First(),
AfterDigest = statement.Subject[1].Digest.Values.First(),
BeforeRevisionId = statement.Predicate.BeforeRevisionId,
AfterRevisionId = statement.Predicate.AfterRevisionId,
HasMaterialChange = statement.Predicate.HasMaterialChange,
PriorityScore = statement.Predicate.PriorityScore,
ComparedAt = statement.Predicate.ComparedAt,
Changes = statement.Predicate.Changes
.Select(c => new DeltaChange
{
Rule = c.Rule,
FindingKey = c.FindingKey,
Direction = c.Direction,
Reason = c.Reason
})
.ToArray()
};
}
#endregion
}

View File

@@ -0,0 +1,531 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_4400_0001_0002_reachability_subgraph_attestation
// Task: SUBG-008 - Integration tests for reachability subgraph attestation
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Scanner.SmartDiffTests.Integration;
/// <summary>
/// Integration tests for reachability subgraph attestation flow.
/// Sprint: SPRINT_4400_0001_0002 - Reachability Subgraph Attestation.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Sprint", "4400.2")]
public sealed class ReachabilitySubgraphAttestationTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
#region Subgraph Structure Tests
[Fact(DisplayName = "Subgraph contains entrypoint nodes")]
public void Subgraph_ContainsEntrypointNodes()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.Nodes.Should().Contain(n => n.Type == "entrypoint");
}
[Fact(DisplayName = "Subgraph contains vulnerable nodes")]
public void Subgraph_ContainsVulnerableNodes()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.Nodes.Should().Contain(n => n.Type == "vulnerable");
}
[Fact(DisplayName = "Subgraph has valid edge connections")]
public void Subgraph_HasValidEdgeConnections()
{
// Arrange
var subgraph = CreateTestSubgraph();
var nodeIds = subgraph.Nodes.Select(n => n.Id).ToHashSet();
// Assert - All edges reference valid nodes
foreach (var edge in subgraph.Edges)
{
nodeIds.Should().Contain(edge.From, $"Edge from node {edge.From} should exist");
nodeIds.Should().Contain(edge.To, $"Edge to node {edge.To} should exist");
}
}
[Fact(DisplayName = "Subgraph includes finding keys")]
public void Subgraph_IncludesFindingKeys()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.FindingKeys.Should().NotBeEmpty();
subgraph.FindingKeys.Should().Contain("CVE-2025-0001@pkg:npm/lodash@4.17.20");
}
#endregion
#region Normalization Tests
[Fact(DisplayName = "Subgraph normalization is deterministic")]
public void SubgraphNormalization_IsDeterministic()
{
// Arrange
var subgraph1 = CreateTestSubgraph();
var subgraph2 = CreateTestSubgraph();
// Act
var normalized1 = NormalizeSubgraph(subgraph1);
var normalized2 = NormalizeSubgraph(subgraph2);
var json1 = JsonSerializer.Serialize(normalized1, JsonOptions);
var json2 = JsonSerializer.Serialize(normalized2, JsonOptions);
// Assert
json1.Should().Be(json2, "Normalized subgraphs should be deterministic");
}
[Fact(DisplayName = "Normalization sorts nodes by ID")]
public void Normalization_SortsNodesById()
{
// Arrange
var subgraph = CreateUnorderedSubgraph();
// Act
var normalized = NormalizeSubgraph(subgraph);
// Assert
var nodeIds = normalized.Nodes.Select(n => n.Id).ToList();
nodeIds.Should().BeInAscendingOrder();
}
[Fact(DisplayName = "Normalization sorts edges")]
public void Normalization_SortsEdges()
{
// Arrange
var subgraph = CreateUnorderedSubgraph();
// Act
var normalized = NormalizeSubgraph(subgraph);
// Assert
var edgeKeys = normalized.Edges.Select(e => $"{e.From}->{e.To}").ToList();
edgeKeys.Should().BeInAscendingOrder();
}
#endregion
#region Serialization Tests
[Fact(DisplayName = "Subgraph round-trips through JSON")]
public void Subgraph_RoundTrips_ThroughJson()
{
// Arrange
var original = CreateTestSubgraph();
// Act
var json = JsonSerializer.Serialize(original, JsonOptions);
var deserialized = JsonSerializer.Deserialize<TestReachabilitySubgraph>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Version.Should().Be(original.Version);
deserialized.FindingKeys.Should().BeEquivalentTo(original.FindingKeys);
deserialized.Nodes.Should().HaveCount(original.Nodes.Length);
deserialized.Edges.Should().HaveCount(original.Edges.Length);
}
[Fact(DisplayName = "Subgraph JSON matches expected format")]
public void Subgraph_JsonFormat_MatchesExpected()
{
// Arrange
var subgraph = CreateMinimalSubgraph();
// Act
var json = JsonSerializer.Serialize(subgraph, JsonOptions);
// Assert
json.Should().Contain("\"version\"");
json.Should().Contain("\"findingKeys\"");
json.Should().Contain("\"nodes\"");
json.Should().Contain("\"edges\"");
json.Should().Contain("\"analysisMetadata\"");
}
#endregion
#region DOT Export Tests
[Fact(DisplayName = "DOT export includes digraph declaration")]
public void DotExport_IncludesDigraphDeclaration()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var dot = GenerateDot(subgraph, null);
// Assert
dot.Should().StartWith("digraph reachability {");
dot.Should().EndWith("}\n");
}
[Fact(DisplayName = "DOT export includes all nodes")]
public void DotExport_IncludesAllNodes()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var dot = GenerateDot(subgraph, null);
// Assert
foreach (var node in subgraph.Nodes)
{
dot.Should().Contain($"\"{node.Id}\"");
}
}
[Fact(DisplayName = "DOT export includes all edges")]
public void DotExport_IncludesAllEdges()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var dot = GenerateDot(subgraph, null);
// Assert
foreach (var edge in subgraph.Edges)
{
dot.Should().Contain($"\"{edge.From}\" -> \"{edge.To}\"");
}
}
[Fact(DisplayName = "DOT export colors nodes by type")]
public void DotExport_ColorsNodesByType()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var dot = GenerateDot(subgraph, null);
// Assert
dot.Should().Contain("lightgreen"); // Entrypoint
dot.Should().Contain("lightcoral"); // Vulnerable
}
#endregion
#region Mermaid Export Tests
[Fact(DisplayName = "Mermaid export includes graph declaration")]
public void MermaidExport_IncludesGraphDeclaration()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var mermaid = GenerateMermaid(subgraph, null);
// Assert
mermaid.Should().Contain("graph LR");
}
[Fact(DisplayName = "Mermaid export includes subgraphs for node types")]
public void MermaidExport_IncludesSubgraphsForNodeTypes()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var mermaid = GenerateMermaid(subgraph, null);
// Assert
mermaid.Should().Contain("subgraph Entrypoints");
mermaid.Should().Contain("subgraph Vulnerable");
}
[Fact(DisplayName = "Mermaid export includes class definitions")]
public void MermaidExport_IncludesClassDefinitions()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Act
var mermaid = GenerateMermaid(subgraph, null);
// Assert
mermaid.Should().Contain("classDef entrypoint");
mermaid.Should().Contain("classDef vulnerable");
}
#endregion
#region Analysis Metadata Tests
[Fact(DisplayName = "Analysis metadata includes analyzer info")]
public void AnalysisMetadata_IncludesAnalyzerInfo()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.AnalysisMetadata.Should().NotBeNull();
subgraph.AnalysisMetadata!.Analyzer.Should().NotBeNullOrEmpty();
subgraph.AnalysisMetadata.AnalyzerVersion.Should().NotBeNullOrEmpty();
}
[Fact(DisplayName = "Analysis metadata includes confidence score")]
public void AnalysisMetadata_IncludesConfidenceScore()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.AnalysisMetadata!.Confidence.Should().BeInRange(0, 1);
}
[Fact(DisplayName = "Analysis metadata includes completeness")]
public void AnalysisMetadata_IncludesCompleteness()
{
// Arrange
var subgraph = CreateTestSubgraph();
// Assert
subgraph.AnalysisMetadata!.Completeness.Should().BeOneOf("full", "partial", "sampling");
}
#endregion
#region Helper Methods
private static TestReachabilitySubgraph CreateTestSubgraph()
{
return new TestReachabilitySubgraph
{
Version = "1.0",
FindingKeys = new[] { "CVE-2025-0001@pkg:npm/lodash@4.17.20" },
Nodes = new[]
{
new TestNode { Id = "n1", Type = "entrypoint", Symbol = "main.handler", File = "src/main.js", Line = 10 },
new TestNode { Id = "n2", Type = "call", Symbol = "lodash.merge", File = "node_modules/lodash/merge.js", Line = 50 },
new TestNode { Id = "n3", Type = "vulnerable", Symbol = "lodash._baseAssign", File = "node_modules/lodash/_baseAssign.js", Line = 12, Purl = "pkg:npm/lodash@4.17.20" }
},
Edges = new[]
{
new TestEdge { From = "n1", To = "n2", Type = "call", Confidence = 0.95 },
new TestEdge { From = "n2", To = "n3", Type = "call", Confidence = 0.90 }
},
AnalysisMetadata = new TestAnalysisMetadata
{
Analyzer = "node-callgraph-v2",
AnalyzerVersion = "2.1.0",
Confidence = 0.92,
Completeness = "partial"
}
};
}
private static TestReachabilitySubgraph CreateUnorderedSubgraph()
{
return new TestReachabilitySubgraph
{
Version = "1.0",
FindingKeys = new[] { "CVE-2025-0001" },
Nodes = new[]
{
new TestNode { Id = "z-node", Type = "call", Symbol = "z.func" },
new TestNode { Id = "a-node", Type = "entrypoint", Symbol = "a.main" },
new TestNode { Id = "m-node", Type = "vulnerable", Symbol = "m.vuln" }
},
Edges = new[]
{
new TestEdge { From = "z-node", To = "m-node", Type = "call", Confidence = 1.0 },
new TestEdge { From = "a-node", To = "z-node", Type = "call", Confidence = 1.0 }
},
AnalysisMetadata = new TestAnalysisMetadata
{
Analyzer = "test",
AnalyzerVersion = "1.0",
Confidence = 1.0,
Completeness = "full"
}
};
}
private static TestReachabilitySubgraph CreateMinimalSubgraph()
{
return new TestReachabilitySubgraph
{
Version = "1.0",
FindingKeys = new[] { "CVE-2025-MINIMAL" },
Nodes = new[]
{
new TestNode { Id = "entry", Type = "entrypoint", Symbol = "main" },
new TestNode { Id = "vuln", Type = "vulnerable", Symbol = "vuln.func" }
},
Edges = new[]
{
new TestEdge { From = "entry", To = "vuln", Type = "call", Confidence = 1.0 }
},
AnalysisMetadata = new TestAnalysisMetadata
{
Analyzer = "minimal",
AnalyzerVersion = "1.0",
Confidence = 1.0,
Completeness = "full"
}
};
}
private static TestReachabilitySubgraph NormalizeSubgraph(TestReachabilitySubgraph subgraph)
{
return subgraph with
{
Nodes = subgraph.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal).ToArray(),
Edges = subgraph.Edges
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ToArray(),
FindingKeys = subgraph.FindingKeys.OrderBy(k => k, StringComparer.Ordinal).ToArray()
};
}
private static string GenerateDot(TestReachabilitySubgraph subgraph, string? title)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("digraph reachability {");
sb.AppendLine(" rankdir=LR;");
sb.AppendLine(" node [shape=box, fontname=\"Helvetica\"];");
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($" label=\"{title}\";");
}
foreach (var node in subgraph.Nodes)
{
var color = node.Type switch
{
"entrypoint" => "lightgreen",
"vulnerable" => "lightcoral",
_ => "lightyellow"
};
sb.AppendLine($" \"{node.Id}\" [label=\"{node.Symbol}\", fillcolor=\"{color}\", style=\"filled\"];");
}
foreach (var edge in subgraph.Edges)
{
sb.AppendLine($" \"{edge.From}\" -> \"{edge.To}\";");
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateMermaid(TestReachabilitySubgraph subgraph, string? title)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine("---");
sb.AppendLine($"title: {title}");
sb.AppendLine("---");
}
sb.AppendLine("graph LR");
var entrypoints = subgraph.Nodes.Where(n => n.Type == "entrypoint").ToList();
var vulnerables = subgraph.Nodes.Where(n => n.Type == "vulnerable").ToList();
if (entrypoints.Count > 0)
{
sb.AppendLine(" subgraph Entrypoints");
foreach (var node in entrypoints)
{
sb.AppendLine($" {node.Id}([{node.Symbol}])");
}
sb.AppendLine(" end");
}
if (vulnerables.Count > 0)
{
sb.AppendLine(" subgraph Vulnerable");
foreach (var node in vulnerables)
{
sb.AppendLine($" {node.Id}{{{{{node.Symbol}}}}}");
}
sb.AppendLine(" end");
}
foreach (var edge in subgraph.Edges)
{
sb.AppendLine($" {edge.From} --> {edge.To}");
}
sb.AppendLine(" classDef entrypoint fill:#90EE90,stroke:#333");
sb.AppendLine(" classDef vulnerable fill:#F08080,stroke:#333");
return sb.ToString();
}
#endregion
}
#region Test Models
public sealed record TestReachabilitySubgraph
{
public string Version { get; init; } = "1.0";
public string[] FindingKeys { get; init; } = Array.Empty<string>();
public TestNode[] Nodes { get; init; } = Array.Empty<TestNode>();
public TestEdge[] Edges { get; init; } = Array.Empty<TestEdge>();
public TestAnalysisMetadata? AnalysisMetadata { get; init; }
}
public sealed record TestNode
{
public required string Id { get; init; }
public required string Type { get; init; }
public string? Symbol { get; init; }
public string? File { get; init; }
public int? Line { get; init; }
public string? Purl { get; init; }
}
public sealed record TestEdge
{
public required string From { get; init; }
public required string To { get; init; }
public string? Type { get; init; }
public double Confidence { get; init; }
public TestGateInfo? Gate { get; init; }
}
public sealed record TestGateInfo
{
public required string GateType { get; init; }
public string? Condition { get; init; }
}
public sealed record TestAnalysisMetadata
{
public required string Analyzer { get; init; }
public required string AnalyzerVersion { get; init; }
public double Confidence { get; init; }
public required string Completeness { get; init; }
}
#endregion

View File

@@ -238,6 +238,53 @@ public sealed class SarifOutputGeneratorTests
sarifLog.Runs[0].Invocations!.Value[0].StartTimeUtc.Should().Be(scanTime);
}
[Fact(DisplayName = "Attestation reference included in run properties")]
[Trait("Sprint", "4400.1")]
public void AttestationReference_IncludedInRunProperties()
{
// Arrange - Sprint SPRINT_4400_0001_0001 - DELTA-007
var input = CreateBasicInput() with
{
Attestation = new AttestationReference(
Digest: "sha256:attestation123",
PredicateType: "delta-verdict.stella/v1",
OciReference: "registry.example.com/repo@sha256:attestation123",
RekorLogId: "1234567890",
SignatureKeyId: "delta-dev")
};
// Act
var sarifLog = _generator.Generate(input);
// Assert
sarifLog.Runs[0].Properties.Should().NotBeNull();
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.attestation");
}
[Fact(DisplayName = "Base and target digests included in run properties")]
[Trait("Sprint", "4400.1")]
public void BaseAndTargetDigests_IncludedInRunProperties()
{
// Arrange
var input = new SmartDiffSarifInput(
ScannerVersion: "1.0.0",
ScanTime: DateTimeOffset.UtcNow,
BaseDigest: "sha256:base-digest-abc",
TargetDigest: "sha256:target-digest-xyz",
MaterialChanges: [],
HardeningRegressions: [],
VexCandidates: [],
ReachabilityChanges: []);
// Act
var sarifLog = _generator.Generate(input);
// Assert
sarifLog.Runs[0].Properties.Should().NotBeNull();
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.diff.base.digest");
sarifLog.Runs[0].Properties!.Should().ContainKey("stellaops.diff.target.digest");
}
#endregion
#region Determinism Tests (SDIFF-BIN-027)

View File

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Testcontainers" Version="4.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>

View File

@@ -0,0 +1,442 @@
// -----------------------------------------------------------------------------
// VerdictE2ETests.cs
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
// Task: VERDICT-015
// Description: End-to-end tests for scan -> verdict push -> verify workflow.
// -----------------------------------------------------------------------------
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
/// <summary>
/// End-to-end tests validating the complete verdict attestation workflow:
/// 1. Push a container image to registry
/// 2. Create and push a verdict attestation as a referrer
/// 3. Verify the verdict can be discovered and validated
/// </summary>
[Trait("Category", "E2E")]
public sealed class VerdictE2ETests : IAsyncLifetime
{
private IContainer? _registryContainer;
private string _registryHost = string.Empty;
private HttpClient? _httpClient;
public async Task InitializeAsync()
{
_registryContainer = new ContainerBuilder()
.WithImage("registry:2")
.WithPortBinding(5000, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5000))
.Build();
await _registryContainer.StartAsync();
var port = _registryContainer.GetMappedPublicPort(5000);
_registryHost = $"localhost:{port}";
_httpClient = new HttpClient();
}
public async Task DisposeAsync()
{
_httpClient?.Dispose();
if (_registryContainer is not null)
{
await _registryContainer.DisposeAsync();
}
}
/// <summary>
/// Full E2E test: simulates a scan completion -> verdict push -> verification flow.
/// </summary>
[Fact]
public async Task E2E_ScanVerdictPushVerify_CompletesSuccessfully()
{
// ===== PHASE 1: Simulate scan output (push base image) =====
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/myapp");
// ===== PHASE 2: Create and push verdict attestation =====
var scanResult = CreateMockScanResult(imageDigest);
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/myapp", imageDigest, scanResult);
// ===== PHASE 3: Verify verdict via referrers API =====
var verificationResult = await VerifyVerdictAsync("e2e-test/myapp", imageDigest, verdictDigest);
// Assert all phases completed successfully
Assert.True(verificationResult.VerdictFound, "Verdict should be discoverable");
Assert.Equal(scanResult.Decision, verificationResult.Decision);
Assert.Equal(scanResult.SbomDigest, verificationResult.SbomDigest);
Assert.Equal(scanResult.FeedsDigest, verificationResult.FeedsDigest);
Assert.Equal(scanResult.PolicyDigest, verificationResult.PolicyDigest);
}
/// <summary>
/// E2E test: multiple scan revisions create separate verdict attestations.
/// </summary>
[Fact]
public async Task E2E_MultipleScanRevisions_CreatesMultipleVerdicts()
{
// Setup
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/versioned");
// First scan/verdict
var scanResult1 = new MockScanResult
{
Decision = "pass",
SbomDigest = "sha256:sbom_rev1",
FeedsDigest = "sha256:feeds_rev1",
PolicyDigest = "sha256:policy_rev1",
GraphRevisionId = "rev-001"
};
var verdict1 = await PushVerdictAttestationAsync("e2e-test/versioned", imageDigest, scanResult1);
// Second scan/verdict (updated feeds)
var scanResult2 = new MockScanResult
{
Decision = "warn",
SbomDigest = "sha256:sbom_rev1", // Same SBOM
FeedsDigest = "sha256:feeds_rev2", // Updated feeds
PolicyDigest = "sha256:policy_rev1",
GraphRevisionId = "rev-002"
};
var verdict2 = await PushVerdictAttestationAsync("e2e-test/versioned", imageDigest, scanResult2);
// Verify both verdicts exist
var verdicts = await ListVerdictsAsync("e2e-test/versioned", imageDigest);
Assert.Equal(2, verdicts.Count);
Assert.Contains(verdicts, v => v.Decision == "pass" && v.GraphRevisionId == "rev-001");
Assert.Contains(verdicts, v => v.Decision == "warn" && v.GraphRevisionId == "rev-002");
}
/// <summary>
/// E2E test: verdict with uncertainty attestation references (SPRINT_4300_0002_0002).
/// </summary>
[Fact]
public async Task E2E_VerdictWithUncertainty_IncludesUncertaintyDigests()
{
// Setup
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/uncertain");
var scanResult = new MockScanResult
{
Decision = "pass",
SbomDigest = "sha256:sbom_uncertain",
FeedsDigest = "sha256:feeds_uncertain",
PolicyDigest = "sha256:policy_uncertain",
UncertaintyStatementDigest = "sha256:uncertainty_t2",
UncertaintyBudgetDigest = "sha256:budget_passed"
};
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/uncertain", imageDigest, scanResult);
// Fetch and verify manifest annotations include uncertainty
var manifest = await FetchManifestAsync("e2e-test/uncertain", verdictDigest);
Assert.True(manifest.TryGetProperty("annotations", out var annotations));
Assert.Equal("sha256:uncertainty_t2",
annotations.GetProperty(OciAnnotations.StellaUncertaintyDigest).GetString());
Assert.Equal("sha256:budget_passed",
annotations.GetProperty(OciAnnotations.StellaUncertaintyBudgetDigest).GetString());
}
/// <summary>
/// E2E test: verify verdict DSSE envelope can be fetched and parsed.
/// </summary>
[Fact]
public async Task E2E_VerdictDsseEnvelope_CanBeFetchedAndParsed()
{
// Setup
var imageDigest = await SimulateScanAndPushImageAsync("e2e-test/dsse");
var scanResult = CreateMockScanResult(imageDigest);
var verdictDigest = await PushVerdictAttestationAsync("e2e-test/dsse", imageDigest, scanResult);
// Fetch manifest to get layer digest
var manifest = await FetchManifestAsync("e2e-test/dsse", verdictDigest);
var layers = manifest.GetProperty("layers");
Assert.Equal(1, layers.GetArrayLength());
var layerDigest = layers[0].GetProperty("digest").GetString();
Assert.NotNull(layerDigest);
// Fetch the DSSE envelope blob
var blobUrl = $"http://{_registryHost}/v2/e2e-test/dsse/blobs/{layerDigest}";
var blobResponse = await _httpClient!.GetAsync(blobUrl);
blobResponse.EnsureSuccessStatusCode();
var envelopeBytes = await blobResponse.Content.ReadAsByteArrayAsync();
var envelope = JsonSerializer.Deserialize<JsonElement>(envelopeBytes);
// Verify DSSE envelope structure
Assert.Equal("verdict.stella/v1", envelope.GetProperty("payloadType").GetString());
Assert.True(envelope.TryGetProperty("payload", out var payload));
Assert.False(string.IsNullOrWhiteSpace(payload.GetString()));
}
// ===== Helper Methods =====
private async Task<string> SimulateScanAndPushImageAsync(string repository)
{
// Create a minimal image config (simulates scan target)
var config = $$"""
{
"created": "{{DateTimeOffset.UtcNow:O}}",
"architecture": "amd64",
"os": "linux",
"rootfs": {"type": "layers", "diff_ids": []},
"config": {}
}
""";
var configBytes = Encoding.UTF8.GetBytes(config);
var configDigest = ComputeSha256Digest(configBytes);
await PushBlobAsync(repository, configDigest, configBytes);
// Create image manifest
var manifest = $$"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "{{configDigest}}",
"size": {{configBytes.Length}}
},
"layers": []
}
""";
var manifestBytes = Encoding.UTF8.GetBytes(manifest);
var manifestDigest = ComputeSha256Digest(manifestBytes);
var manifestUrl = $"http://{_registryHost}/v2/{repository}/manifests/{manifestDigest}";
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl);
request.Content = new ByteArrayContent(manifestBytes);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
return manifestDigest;
}
private async Task<string> PushVerdictAttestationAsync(string repository, string imageDigest, MockScanResult scanResult)
{
var pusher = new OciArtifactPusher(
_httpClient!,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = _registryHost },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/{repository}",
ImageDigest = imageDigest,
DsseEnvelopeBytes = CreateDsseEnvelope(scanResult),
SbomDigest = scanResult.SbomDigest,
FeedsDigest = scanResult.FeedsDigest,
PolicyDigest = scanResult.PolicyDigest,
Decision = scanResult.Decision,
GraphRevisionId = scanResult.GraphRevisionId,
VerdictTimestamp = DateTimeOffset.UtcNow,
UncertaintyStatementDigest = scanResult.UncertaintyStatementDigest,
UncertaintyBudgetDigest = scanResult.UncertaintyBudgetDigest
};
var result = await verdictPublisher.PushAsync(request);
Assert.True(result.Success, $"Verdict push failed: {result.Error}");
return result.ManifestDigest!;
}
private async Task<VerdictVerificationInfo> VerifyVerdictAsync(string repository, string imageDigest, string expectedVerdictDigest)
{
// Query referrers API
var referrersUrl = $"http://{_registryHost}/v2/{repository}/referrers/{imageDigest}";
var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
var referrersJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(referrersJson);
var manifests = doc.RootElement.GetProperty("manifests");
foreach (var manifest in manifests.EnumerateArray())
{
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
{
var annotations = manifest.GetProperty("annotations");
return new VerdictVerificationInfo
{
VerdictFound = true,
VerdictDigest = manifest.GetProperty("digest").GetString(),
Decision = annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString(),
SbomDigest = annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString(),
FeedsDigest = annotations.GetProperty(OciAnnotations.StellaFeedsDigest).GetString(),
PolicyDigest = annotations.GetProperty(OciAnnotations.StellaPolicyDigest).GetString()
};
}
}
return new VerdictVerificationInfo { VerdictFound = false };
}
private async Task<List<VerdictListItem>> ListVerdictsAsync(string repository, string imageDigest)
{
var referrersUrl = $"http://{_registryHost}/v2/{repository}/referrers/{imageDigest}";
var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
var referrersJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(referrersJson);
var results = new List<VerdictListItem>();
var manifests = doc.RootElement.GetProperty("manifests");
foreach (var manifest in manifests.EnumerateArray())
{
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
{
var annotations = manifest.GetProperty("annotations");
results.Add(new VerdictListItem
{
Digest = manifest.GetProperty("digest").GetString()!,
Decision = annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString()!,
GraphRevisionId = annotations.TryGetProperty(OciAnnotations.StellaGraphRevisionId, out var rev)
? rev.GetString()
: null
});
}
}
return results;
}
private async Task<JsonElement> FetchManifestAsync(string repository, string digest)
{
var manifestUrl = $"http://{_registryHost}/v2/{repository}/manifests/{digest}";
var request = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
var manifestJson = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(manifestJson).RootElement.Clone();
}
private async Task PushBlobAsync(string repository, string digest, byte[] content)
{
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
initiateResponse.EnsureSuccessStatusCode();
var uploadLocation = initiateResponse.Headers.Location?.ToString();
Assert.NotNull(uploadLocation);
var separator = uploadLocation.Contains('?') ? "&" : "?";
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
uploadUrl = $"http://{_registryHost}{uploadUrl}";
}
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
uploadRequest.Content = new ByteArrayContent(content);
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
uploadResponse.EnsureSuccessStatusCode();
}
private static string ComputeSha256Digest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static MockScanResult CreateMockScanResult(string imageDigest)
{
return new MockScanResult
{
Decision = "pass",
SbomDigest = $"sha256:sbom_{imageDigest[7..19]}",
FeedsDigest = $"sha256:feeds_{DateTimeOffset.UtcNow:yyyyMMddHH}",
PolicyDigest = "sha256:policy_default_v1",
GraphRevisionId = $"rev-{Guid.NewGuid():N}"[..16]
};
}
private static byte[] CreateDsseEnvelope(MockScanResult scanResult)
{
var payload = JsonSerializer.Serialize(new
{
decision = scanResult.Decision,
sbomDigest = scanResult.SbomDigest,
feedsDigest = scanResult.FeedsDigest,
policyDigest = scanResult.PolicyDigest,
graphRevisionId = scanResult.GraphRevisionId,
timestamp = DateTimeOffset.UtcNow
});
var envelope = new
{
payloadType = "verdict.stella/v1",
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
signatures = Array.Empty<object>()
};
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(envelope));
}
// ===== Model Classes =====
private sealed class MockScanResult
{
public required string Decision { get; init; }
public required string SbomDigest { get; init; }
public required string FeedsDigest { get; init; }
public required string PolicyDigest { get; init; }
public string? GraphRevisionId { get; init; }
public string? UncertaintyStatementDigest { get; init; }
public string? UncertaintyBudgetDigest { get; init; }
}
private sealed class VerdictVerificationInfo
{
public bool VerdictFound { get; init; }
public string? VerdictDigest { get; init; }
public string? Decision { get; init; }
public string? SbomDigest { get; init; }
public string? FeedsDigest { get; init; }
public string? PolicyDigest { get; init; }
}
private sealed class VerdictListItem
{
public required string Digest { get; init; }
public required string Decision { get; init; }
public string? GraphRevisionId { get; init; }
}
}

View File

@@ -0,0 +1,363 @@
// -----------------------------------------------------------------------------
// VerdictOciPublisherIntegrationTests.cs
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
// Task: VERDICT-010
// Description: Integration tests for verdict push with local OCI registry.
// -----------------------------------------------------------------------------
using System.Net.Http.Headers;
using System.Text.Json;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
/// <summary>
/// Integration tests for VerdictOciPublisher using a real OCI registry (Distribution).
/// These tests require Docker to be running.
/// </summary>
[Trait("Category", "Integration")]
public sealed class VerdictOciPublisherIntegrationTests : IAsyncLifetime
{
private IContainer? _registryContainer;
private string _registryHost = string.Empty;
private HttpClient? _httpClient;
public async Task InitializeAsync()
{
// Start a local OCI Distribution registry container
_registryContainer = new ContainerBuilder()
.WithImage("registry:2")
.WithPortBinding(5000, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5000))
.Build();
await _registryContainer.StartAsync();
var port = _registryContainer.GetMappedPublicPort(5000);
_registryHost = $"localhost:{port}";
_httpClient = new HttpClient();
}
public async Task DisposeAsync()
{
_httpClient?.Dispose();
if (_registryContainer is not null)
{
await _registryContainer.DisposeAsync();
}
}
[Fact]
public async Task PushAsync_ToLocalRegistry_SuccessfullyPushesVerdict()
{
// Arrange
// First, we need to push a base image that the verdict will reference
var baseImageDigest = await PushBaseImageAsync();
var pusher = new OciArtifactPusher(
_httpClient!,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = _registryHost },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var verdictEnvelope = CreateTestDsseEnvelope("pass");
var request = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/test/app",
ImageDigest = baseImageDigest,
DsseEnvelopeBytes = verdictEnvelope,
SbomDigest = "sha256:sbom123",
FeedsDigest = "sha256:feeds456",
PolicyDigest = "sha256:policy789",
Decision = "pass",
GraphRevisionId = "integration-test-rev-001",
VerdictTimestamp = DateTimeOffset.UtcNow
};
// Act
var result = await verdictPublisher.PushAsync(request);
// Assert
Assert.True(result.Success, $"Push failed: {result.Error}");
Assert.NotNull(result.ManifestDigest);
Assert.StartsWith("sha256:", result.ManifestDigest);
}
[Fact]
public async Task PushAsync_ToLocalRegistry_VerdictIsDiscoverableViaReferrersApi()
{
// Arrange
var baseImageDigest = await PushBaseImageAsync();
var pusher = new OciArtifactPusher(
_httpClient!,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = _registryHost },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/test/app",
ImageDigest = baseImageDigest,
DsseEnvelopeBytes = CreateTestDsseEnvelope("warn"),
SbomDigest = "sha256:sbom_referrer_test",
FeedsDigest = "sha256:feeds_referrer_test",
PolicyDigest = "sha256:policy_referrer_test",
Decision = "warn"
};
// Act
var pushResult = await verdictPublisher.PushAsync(request);
Assert.True(pushResult.Success, $"Push failed: {pushResult.Error}");
// Query the referrers API
var referrersUrl = $"http://{_registryHost}/v2/test/app/referrers/{baseImageDigest}";
var referrersRequest = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
referrersRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
var response = await _httpClient!.SendAsync(referrersRequest);
// Assert
Assert.True(response.IsSuccessStatusCode, $"Referrers API failed: {response.StatusCode}");
var referrersJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(referrersJson);
Assert.True(doc.RootElement.TryGetProperty("manifests", out var manifests));
Assert.True(manifests.GetArrayLength() > 0, "No referrers found");
// Find our verdict referrer
var verdictFound = false;
foreach (var manifest in manifests.EnumerateArray())
{
if (manifest.TryGetProperty("artifactType", out var artifactType) &&
artifactType.GetString() == OciMediaTypes.VerdictAttestation)
{
verdictFound = true;
// Verify annotations
Assert.True(manifest.TryGetProperty("annotations", out var annotations));
Assert.Equal("warn", annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString());
Assert.Equal("sha256:sbom_referrer_test", annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString());
break;
}
}
Assert.True(verdictFound, "Verdict attestation not found in referrers");
}
[Fact]
public async Task PushAsync_MultipleTimes_CreatesSeparateReferrers()
{
// Arrange
var baseImageDigest = await PushBaseImageAsync();
var pusher = new OciArtifactPusher(
_httpClient!,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = _registryHost },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
// Act - Push two different verdicts
var request1 = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/test/app",
ImageDigest = baseImageDigest,
DsseEnvelopeBytes = CreateTestDsseEnvelope("pass"),
SbomDigest = "sha256:sbom_v1",
FeedsDigest = "sha256:feeds_v1",
PolicyDigest = "sha256:policy_v1",
Decision = "pass"
};
var request2 = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/test/app",
ImageDigest = baseImageDigest,
DsseEnvelopeBytes = CreateTestDsseEnvelope("block"),
SbomDigest = "sha256:sbom_v2",
FeedsDigest = "sha256:feeds_v2",
PolicyDigest = "sha256:policy_v2",
Decision = "block"
};
var result1 = await verdictPublisher.PushAsync(request1);
var result2 = await verdictPublisher.PushAsync(request2);
// Assert
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.NotEqual(result1.ManifestDigest, result2.ManifestDigest);
// Query referrers
var referrersUrl = $"http://{_registryHost}/v2/test/app/referrers/{baseImageDigest}";
var referrersRequest = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
referrersRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
var response = await _httpClient!.SendAsync(referrersRequest);
var referrersJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(referrersJson);
var manifests = doc.RootElement.GetProperty("manifests");
var verdictCount = manifests.EnumerateArray()
.Count(m => m.TryGetProperty("artifactType", out var at) &&
at.GetString() == OciMediaTypes.VerdictAttestation);
Assert.Equal(2, verdictCount);
}
[Fact]
public async Task PushAsync_WithUncertaintyDigests_IncludesInAnnotations()
{
// Arrange - SPRINT_4300_0002_0002 integration
var baseImageDigest = await PushBaseImageAsync();
var pusher = new OciArtifactPusher(
_httpClient!,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = _registryHost },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = $"{_registryHost}/test/app",
ImageDigest = baseImageDigest,
DsseEnvelopeBytes = CreateTestDsseEnvelope("pass"),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass",
UncertaintyStatementDigest = "sha256:uncertainty_statement_digest",
UncertaintyBudgetDigest = "sha256:uncertainty_budget_digest"
};
// Act
var result = await verdictPublisher.PushAsync(request);
// Assert
Assert.True(result.Success);
// Fetch the manifest and verify annotations
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/{result.ManifestDigest}";
var manifestRequest = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
manifestRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json"));
var response = await _httpClient!.SendAsync(manifestRequest);
var manifestJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(manifestJson);
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
Assert.Equal("sha256:uncertainty_statement_digest",
annotations.GetProperty(OciAnnotations.StellaUncertaintyDigest).GetString());
Assert.Equal("sha256:uncertainty_budget_digest",
annotations.GetProperty(OciAnnotations.StellaUncertaintyBudgetDigest).GetString());
}
/// <summary>
/// Push a minimal base image that verdicts can reference.
/// </summary>
private async Task<string> PushBaseImageAsync()
{
// Create a minimal OCI image config
var config = """{"created":"2025-12-22T00:00:00Z","architecture":"amd64","os":"linux"}"""u8.ToArray();
var configDigest = ComputeSha256Digest(config);
// Push config blob
await PushBlobAsync("test/app", configDigest, config);
// Create manifest
var manifest = $$"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "{{configDigest}}",
"size": {{config.Length}}
},
"layers": []
}
""";
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifest);
var manifestDigest = ComputeSha256Digest(manifestBytes);
// Push manifest
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/{manifestDigest}";
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl);
request.Content = new ByteArrayContent(manifestBytes);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.oci.image.manifest.v1+json");
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
return manifestDigest;
}
private async Task PushBlobAsync(string repository, string digest, byte[] content)
{
// Initiate upload
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
initiateResponse.EnsureSuccessStatusCode();
var uploadLocation = initiateResponse.Headers.Location?.ToString();
Assert.NotNull(uploadLocation);
// Complete upload
var separator = uploadLocation.Contains('?') ? "&" : "?";
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
uploadUrl = $"http://{_registryHost}{uploadUrl}";
}
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl);
uploadRequest.Content = new ByteArrayContent(content);
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
uploadResponse.EnsureSuccessStatusCode();
}
private static string ComputeSha256Digest(byte[] content)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static byte[] CreateTestDsseEnvelope(string decision)
{
var payload = $$"""
{
"decision": "{{decision}}",
"timestamp": "2025-12-22T00:00:00Z"
}
""";
var envelope = $$"""
{
"payloadType": "verdict.stella/v1",
"payload": "{{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload))}}",
"signatures": []
}
""";
return System.Text.Encoding.UTF8.GetBytes(envelope);
}
}

View File

@@ -0,0 +1,338 @@
// -----------------------------------------------------------------------------
// VerdictOciPublisherTests.cs
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
// Description: Tests for VerdictOciPublisher service.
// -----------------------------------------------------------------------------
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
public sealed class VerdictOciPublisherTests
{
[Fact]
public async Task PushAsync_ValidRequest_PushesVerdictAsReferrer()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance,
timeProvider: new FixedTimeProvider(new DateTimeOffset(2025, 12, 22, 10, 0, 0, TimeSpan.Zero)));
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/myapp/container:v1",
ImageDigest = "sha256:abc123def456",
DsseEnvelopeBytes = """{"payloadType":"verdict.stella/v1","payload":"eyJ0ZXN0IjoidmVyZGljdCJ9","signatures":[]}"""u8.ToArray(),
SbomDigest = "sha256:sbom111",
FeedsDigest = "sha256:feeds222",
PolicyDigest = "sha256:policy333",
Decision = "pass",
GraphRevisionId = "rev-001",
ProofBundleDigest = "sha256:proof444",
VerdictTimestamp = new DateTimeOffset(2025, 12, 22, 10, 0, 0, TimeSpan.Zero)
};
// Act
var result = await verdictPublisher.PushAsync(request);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.ManifestDigest);
Assert.StartsWith("sha256:", result.ManifestDigest);
Assert.NotNull(result.ManifestReference);
Assert.Contains("@sha256:", result.ManifestReference);
}
[Fact]
public async Task PushAsync_ValidRequest_IncludesCorrectArtifactType()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/myapp/container@sha256:abc123",
ImageDigest = "sha256:abc123",
DsseEnvelopeBytes = "{}"u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass"
};
// Act
await verdictPublisher.PushAsync(request);
// Assert
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("artifactType", out var artifactType));
Assert.Equal(OciMediaTypes.VerdictAttestation, artifactType.GetString());
}
[Fact]
public async Task PushAsync_ValidRequest_IncludesSubjectReference()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var imageDigest = "sha256:image_under_test_digest";
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/myapp/container",
ImageDigest = imageDigest,
DsseEnvelopeBytes = "{}"u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "block"
};
// Act
await verdictPublisher.PushAsync(request);
// Assert
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("subject", out var subject));
Assert.True(subject.TryGetProperty("digest", out var digest));
Assert.Equal(imageDigest, digest.GetString());
}
[Fact]
public async Task PushAsync_ValidRequest_IncludesVerdictAnnotations()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/app",
ImageDigest = "sha256:abc",
DsseEnvelopeBytes = "{}"u8.ToArray(),
SbomDigest = "sha256:sbom_digest_value",
FeedsDigest = "sha256:feeds_digest_value",
PolicyDigest = "sha256:policy_digest_value",
Decision = "warn",
GraphRevisionId = "test-revision-id",
ProofBundleDigest = "sha256:proof_bundle_value"
};
// Act
await verdictPublisher.PushAsync(request);
// Assert
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
Assert.Equal(VerdictPredicateTypes.Verdict,
annotations.GetProperty(OciAnnotations.StellaPredicateType).GetString());
Assert.Equal("sha256:sbom_digest_value",
annotations.GetProperty(OciAnnotations.StellaSbomDigest).GetString());
Assert.Equal("sha256:feeds_digest_value",
annotations.GetProperty(OciAnnotations.StellaFeedsDigest).GetString());
Assert.Equal("sha256:policy_digest_value",
annotations.GetProperty(OciAnnotations.StellaPolicyDigest).GetString());
Assert.Equal("warn",
annotations.GetProperty(OciAnnotations.StellaVerdictDecision).GetString());
Assert.Equal("test-revision-id",
annotations.GetProperty(OciAnnotations.StellaGraphRevisionId).GetString());
Assert.Equal("sha256:proof_bundle_value",
annotations.GetProperty(OciAnnotations.StellaProofBundleDigest).GetString());
}
[Fact]
public async Task PushAsync_OptionalFieldsNull_ExcludesFromAnnotations()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/app",
ImageDigest = "sha256:abc",
DsseEnvelopeBytes = "{}"u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass",
// Optional fields left null
GraphRevisionId = null,
ProofBundleDigest = null,
AttestationDigest = null,
VerdictTimestamp = null
};
// Act
await verdictPublisher.PushAsync(request);
// Assert
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("annotations", out var annotations));
// Required fields should be present
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaPredicateType, out _));
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaSbomDigest, out _));
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaFeedsDigest, out _));
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaPolicyDigest, out _));
Assert.True(annotations.TryGetProperty(OciAnnotations.StellaVerdictDecision, out _));
// Optional fields should NOT be present
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaGraphRevisionId, out _));
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaProofBundleDigest, out _));
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaAttestationDigest, out _));
Assert.False(annotations.TryGetProperty(OciAnnotations.StellaVerdictTimestamp, out _));
}
[Fact]
public async Task PushAsync_ValidRequest_LayerHasDsseMediaType()
{
// Arrange
var handler = new TestRegistryHandler();
var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciArtifactPusher>.Instance);
var verdictPublisher = new VerdictOciPublisher(pusher);
var request = new VerdictOciPublishRequest
{
Reference = "registry.example/app",
ImageDigest = "sha256:abc",
DsseEnvelopeBytes = """{"payloadType":"test","payload":"dGVzdA==","signatures":[]}"""u8.ToArray(),
SbomDigest = "sha256:sbom",
FeedsDigest = "sha256:feeds",
PolicyDigest = "sha256:policy",
Decision = "pass"
};
// Act
await verdictPublisher.PushAsync(request);
// Assert
Assert.NotNull(handler.ManifestBytes);
using var doc = JsonDocument.Parse(handler.ManifestBytes!);
Assert.True(doc.RootElement.TryGetProperty("layers", out var layers));
Assert.Equal(1, layers.GetArrayLength());
var layer = layers[0];
Assert.Equal(OciMediaTypes.DsseEnvelope, layer.GetProperty("mediaType").GetString());
}
[Fact]
public void VerdictPredicateTypes_Verdict_MatchesExpectedUri()
{
// Assert
Assert.Equal("verdict.stella/v1", VerdictPredicateTypes.Verdict);
}
[Fact]
public void OciMediaTypes_VerdictAttestation_HasCorrectFormat()
{
// Assert
Assert.Equal("application/vnd.stellaops.verdict.v1+json", OciMediaTypes.VerdictAttestation);
}
private sealed class TestRegistryHandler : HttpMessageHandler
{
public byte[]? ManifestBytes { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
{
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
response.Headers.Location = new Uri("/v2/app/blobs/uploads/upload-id", UriKind.Relative);
return response;
}
if (request.Method == HttpMethod.Put && path.Contains("/blobs/uploads/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.Created);
}
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
ManifestBytes = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Created);
}
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _time;
public FixedTimeProvider(DateTimeOffset time) => _time = time;
public override DateTimeOffset GetUtcNow() => _time;
}
}

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// ActionablesEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: Integration tests for actionables engine endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for actionables engine endpoints.
/// </summary>
public sealed class ActionablesEndpointsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetDeltaActionables_ValidDeltaId_ReturnsActionables()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("delta-12345678", result!.DeltaId);
Assert.NotNull(result.Actionables);
}
[Fact]
public async Task GetDeltaActionables_SortedByPriority()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
if (result!.Actionables.Count > 1)
{
var priorities = result.Actionables.Select(GetPriorityOrder).ToList();
Assert.True(priorities.SequenceEqual(priorities.Order()));
}
}
[Fact]
public async Task GetActionablesByPriority_Critical_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/critical");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("critical", a.Priority, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public async Task GetActionablesByPriority_InvalidPriority_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/invalid");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetActionablesByType_Upgrade_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/upgrade");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("upgrade", a.Type, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public async Task GetActionablesByType_Vex_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/vex");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("vex", a.Type, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public async Task GetActionablesByType_InvalidType_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/invalid");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetDeltaActionables_IncludesEstimatedEffort()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
foreach (var actionable in result!.Actionables)
{
Assert.NotNull(actionable.EstimatedEffort);
Assert.Contains(actionable.EstimatedEffort, new[] { "trivial", "low", "medium", "high" });
}
}
private static int GetPriorityOrder(ActionableDto actionable)
{
return actionable.Priority.ToLowerInvariant() switch
{
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 4
};
}
}

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// BaselineEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: Integration tests for baseline selection endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for baseline selection endpoints.
/// </summary>
public sealed class BaselineEndpointsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetRecommendations_ValidDigest_ReturnsRecommendations()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("sha256:artifact123", result!.ArtifactDigest);
Assert.NotEmpty(result.Recommendations);
Assert.Contains(result.Recommendations, r => r.IsDefault);
}
[Fact]
public async Task GetRecommendations_WithEnvironment_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotEmpty(result!.Recommendations);
}
[Fact]
public async Task GetRecommendations_IncludesRationale()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
Assert.NotNull(result);
foreach (var rec in result!.Recommendations)
{
Assert.NotEmpty(rec.Rationale);
Assert.NotEmpty(rec.Type);
Assert.NotEmpty(rec.Label);
}
}
[Fact]
public async Task GetRationale_ValidDigests_ReturnsDetailedRationale()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:base123/sha256:head456");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BaselineRationaleResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("sha256:base123", result!.BaseDigest);
Assert.Equal("sha256:head456", result.HeadDigest);
Assert.NotEmpty(result.SelectionType);
Assert.NotEmpty(result.Rationale);
Assert.NotEmpty(result.DetailedExplanation);
}
[Fact]
public async Task GetRationale_IncludesSelectionCriteria()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/rationale/sha256:baseline-base123/sha256:head456");
var result = await response.Content.ReadFromJsonAsync<BaselineRationaleResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotNull(result!.SelectionCriteria);
Assert.NotEmpty(result.SelectionCriteria);
}
[Fact]
public async Task GetRecommendations_DefaultIsFirst()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotEmpty(result!.Recommendations);
Assert.True(result.Recommendations[0].IsDefault);
}
}

View File

@@ -0,0 +1,218 @@
// -----------------------------------------------------------------------------
// CounterfactualEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0005_counterfactuals
// Description: Integration tests for counterfactual analysis endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Endpoints;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for counterfactual analysis endpoints.
/// </summary>
public sealed class CounterfactualEndpointsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task PostCompute_ValidRequest_ReturnsCounterfactuals()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("finding-123", result!.FindingId);
Assert.Equal("Block", result.CurrentVerdict);
Assert.True(result.HasPaths);
Assert.NotEmpty(result.Paths);
Assert.NotEmpty(result.WouldPassIf);
}
[Fact]
public async Task PostCompute_MissingFindingId_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "",
VulnId = "CVE-2021-44228"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostCompute_IncludesVexPath()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Vex");
}
[Fact]
public async Task PostCompute_IncludesReachabilityPath()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Reachability");
}
[Fact]
public async Task PostCompute_IncludesExceptionPath()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Exception");
}
[Fact]
public async Task PostCompute_WithMaxPaths_LimitsResults()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
CurrentVerdict = "Block",
MaxPaths = 2
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.True(result!.Paths.Count <= 2);
}
[Fact]
public async Task GetForFinding_ValidId_ReturnsCounterfactuals()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/finding/finding-123");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("finding-123", result!.FindingId);
}
[Fact]
public async Task GetScanSummary_ValidId_ReturnsSummary()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CounterfactualScanSummaryDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("scan-123", result!.ScanId);
Assert.NotNull(result.Findings);
}
[Fact]
public async Task GetScanSummary_IncludesPathCounts()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/counterfactuals/scan/scan-123/summary");
var result = await response.Content.ReadFromJsonAsync<CounterfactualScanSummaryDto>(SerializerOptions);
Assert.NotNull(result);
Assert.True(result!.TotalBlocked >= 0);
Assert.True(result.WithVexPath >= 0);
Assert.True(result.WithReachabilityPath >= 0);
Assert.True(result.WithUpgradePath >= 0);
Assert.True(result.WithExceptionPath >= 0);
}
[Fact]
public async Task PostCompute_PathsHaveConditions()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new CounterfactualRequestDto
{
FindingId = "finding-123",
VulnId = "CVE-2021-44228",
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
Assert.NotNull(result);
foreach (var path in result!.Paths)
{
Assert.NotEmpty(path.Description);
Assert.NotEmpty(path.Conditions);
foreach (var condition in path.Conditions)
{
Assert.NotEmpty(condition.Field);
Assert.NotEmpty(condition.RequiredValue);
}
}
}
}

View File

@@ -0,0 +1,140 @@
// -----------------------------------------------------------------------------
// DeltaCompareEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: Integration tests for delta compare endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for delta compare endpoints.
/// </summary>
public sealed class DeltaCompareEndpointsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task PostCompare_ValidRequest_ReturnsComparisonResult()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = "sha256:target456",
IncludeVulnerabilities = true,
IncludeComponents = true,
IncludePolicyDiff = true
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotNull(result!.Base);
Assert.NotNull(result.Target);
Assert.NotNull(result.Summary);
Assert.NotEmpty(result.ComparisonId);
Assert.Equal("sha256:base123", result.Base.Digest);
Assert.Equal("sha256:target456", result.Target.Digest);
}
[Fact]
public async Task PostCompare_MissingBaseDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
{
BaseDigest = "",
TargetDigest = "sha256:target456"
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostCompare_MissingTargetDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = ""
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetQuickDiff_ValidDigests_ReturnsQuickSummary()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123&targetDigest=sha256:target456");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<QuickDiffSummaryDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("sha256:base123", result!.BaseDigest);
Assert.Equal("sha256:target456", result.TargetDigest);
Assert.NotEmpty(result.RiskDirection);
Assert.NotEmpty(result.Summary);
}
[Fact]
public async Task GetQuickDiff_MissingDigest_ReturnsBadRequest()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetComparison_NotFound_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/nonexistent-id");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostCompare_DeterministicComparisonId_SameInputsSameId()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = "sha256:target456"
};
var response1 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var result1 = await response1.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
var response2 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var result2 = await response2.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1!.ComparisonId, result2!.ComparisonId);
}
}

View File

@@ -0,0 +1,193 @@
// -----------------------------------------------------------------------------
// TriageStatusEndpointsTests.cs
// Sprint: SPRINT_4200_0001_0001_triage_rest_api
// Description: Integration tests for triage status endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for triage status endpoints.
/// </summary>
public sealed class TriageStatusEndpointsTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetFindingStatus_NotFound_ReturnsNotFound()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/findings/nonexistent-finding");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostUpdateStatus_ValidRequest_ReturnsUpdatedStatus()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new UpdateTriageStatusRequestDto
{
Lane = "MutedVex",
DecisionKind = "VexNotAffected",
Reason = "Vendor confirms not affected"
};
var response = await client.PostAsJsonAsync("/api/v1/triage/findings/finding-123/status", request);
// Note: Will return 404 since finding doesn't exist in test context
Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound);
}
[Fact]
public async Task PostVexStatement_ValidRequest_ReturnsResponse()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new SubmitVexStatementRequestDto
{
Status = "NotAffected",
Justification = "vulnerable_code_not_in_execute_path",
ImpactStatement = "Code path analysis shows vulnerability is not reachable"
};
var response = await client.PostAsJsonAsync("/api/v1/triage/findings/finding-123/vex", request);
// Note: Will return 404 since finding doesn't exist in test context
Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NotFound);
}
[Fact]
public async Task PostQuery_EmptyFilters_ReturnsResults()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
{
Limit = 10
};
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotNull(result!.Findings);
Assert.NotNull(result.Summary);
}
[Fact]
public async Task PostQuery_WithLaneFilter_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
{
Lanes = ["Active", "Blocked"],
Limit = 10
};
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
Assert.NotNull(result);
}
[Fact]
public async Task PostQuery_WithVerdictFilter_FiltersCorrectly()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
{
Verdicts = ["Block"],
Limit = 10
};
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
Assert.NotNull(result);
}
[Fact]
public async Task GetSummary_ValidDigest_ReturnsSummary()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotNull(result!.ByLane);
Assert.NotNull(result.ByVerdict);
}
[Fact]
public async Task GetSummary_IncludesAllLanes()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
Assert.NotNull(result);
var expectedLanes = new[] { "Active", "Blocked", "NeedsException", "MutedReach", "MutedVex", "Compensated" };
foreach (var lane in expectedLanes)
{
Assert.True(result!.ByLane.ContainsKey(lane), $"Expected lane '{lane}' to be present");
}
}
[Fact]
public async Task GetSummary_IncludesAllVerdicts()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/triage/summary?artifactDigest=sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<TriageSummaryDto>(SerializerOptions);
Assert.NotNull(result);
var expectedVerdicts = new[] { "Ship", "Block", "Exception" };
foreach (var verdict in expectedVerdicts)
{
Assert.True(result!.ByVerdict.ContainsKey(verdict), $"Expected verdict '{verdict}' to be present");
}
}
[Fact]
public async Task PostQuery_ResponseIncludesSummary()
{
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var request = new BulkTriageQueryRequestDto
{
Limit = 10
};
var response = await client.PostAsJsonAsync("/api/v1/triage/query", request);
var result = await response.Content.ReadFromJsonAsync<BulkTriageQueryResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.NotNull(result!.Summary);
Assert.True(result.Summary.CanShipCount >= 0);
Assert.True(result.Summary.BlockingCount >= 0);
}
}