save progress
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// <copyright file="ConfidenceCalculatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfidenceCalculator"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ConfidenceCalculatorTests
|
||||
{
|
||||
private readonly ConfidenceCalculator _calculator = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(LatticeState.Unknown, 0.00, 0.29)]
|
||||
[InlineData(LatticeState.StaticReachable, 0.30, 0.49)]
|
||||
[InlineData(LatticeState.StaticUnreachable, 0.50, 0.69)]
|
||||
[InlineData(LatticeState.RuntimeObserved, 0.70, 0.89)]
|
||||
[InlineData(LatticeState.RuntimeUnobserved, 0.70, 0.89)]
|
||||
[InlineData(LatticeState.ConfirmedReachable, 0.90, 1.00)]
|
||||
[InlineData(LatticeState.ConfirmedUnreachable, 0.90, 1.00)]
|
||||
[InlineData(LatticeState.Contested, 0.00, 0.00)]
|
||||
public void GetConfidenceRange_Returns_Correct_Range(LatticeState state, double expectedMin, double expectedMax)
|
||||
{
|
||||
// Act
|
||||
var (min, max) = ConfidenceCalculator.GetConfidenceRange(state);
|
||||
|
||||
// Assert
|
||||
min.Should().Be(expectedMin);
|
||||
max.Should().Be(expectedMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Unknown_Returns_Value_In_Range()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.Unknown, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().BeInRange(0.00, 0.29);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ConfirmedReachable_Returns_High_Confidence()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.ConfirmedReachable, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().BeInRange(0.90, 1.00);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithStaticResult_PathCount_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var baseResult = CreateStaticResult(isReachable: true, pathCount: 1);
|
||||
var highPathResult = CreateStaticResult(isReachable: true, pathCount: 10);
|
||||
|
||||
// Act
|
||||
var baseConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, baseResult, null);
|
||||
var highPathConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, highPathResult, null);
|
||||
|
||||
// Assert
|
||||
highPathConfidence.Should().BeGreaterThan(baseConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithGuards_Decreases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var noGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 0);
|
||||
var withGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 2);
|
||||
|
||||
// Act
|
||||
var noGuardConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, noGuards, null);
|
||||
var withGuardConfidence = _calculator.Calculate(
|
||||
LatticeState.StaticReachable, withGuards, null);
|
||||
|
||||
// Assert
|
||||
withGuardConfidence.Should().BeLessThan(noGuardConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithRuntimeResult_HitCount_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var lowHits = CreateRuntimeResult(wasObserved: true, hitCount: 1);
|
||||
var highHits = CreateRuntimeResult(wasObserved: true, hitCount: 10000);
|
||||
|
||||
// Act
|
||||
var lowHitConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, lowHits);
|
||||
var highHitConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, highHits);
|
||||
|
||||
// Assert
|
||||
highHitConfidence.Should().BeGreaterThan(lowHitConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MultipleEnvironments_Increases_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var singleEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 1);
|
||||
var multiEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 3);
|
||||
|
||||
// Act
|
||||
var singleConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, singleEnv);
|
||||
var multiConfidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, null, multiEnv);
|
||||
|
||||
// Assert
|
||||
multiConfidence.Should().BeGreaterThan(singleConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Confidence_Always_Within_State_Range()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = CreateStaticResult(isReachable: true, pathCount: 100);
|
||||
var runtimeResult = CreateRuntimeResult(wasObserved: true, hitCount: 1000000, environmentCount: 10);
|
||||
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(
|
||||
LatticeState.RuntimeObserved, staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
var (min, max) = ConfidenceCalculator.GetConfidenceRange(LatticeState.RuntimeObserved);
|
||||
confidence.Should().BeInRange(min, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Contested_Returns_Zero()
|
||||
{
|
||||
// Act
|
||||
var confidence = _calculator.Calculate(LatticeState.Contested, 0.5);
|
||||
|
||||
// Assert
|
||||
confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomWeights_Affect_Calculation()
|
||||
{
|
||||
// Arrange - default pathCountBonus is 0.02, custom is 0.05
|
||||
// With pathCount=5, default adds 5*0.02=0.1 bonus, custom adds 5*0.05=0.25 (capped)
|
||||
var customWeights = new ConfidenceWeights
|
||||
{
|
||||
PathCountBonus = 0.05,
|
||||
MaxPathBonus = 0.20
|
||||
};
|
||||
var customCalculator = new ConfidenceCalculator(customWeights);
|
||||
var defaultCalculator = new ConfidenceCalculator();
|
||||
|
||||
var staticResult = CreateStaticResult(isReachable: true, pathCount: 5);
|
||||
|
||||
// Act
|
||||
var customConfidence = customCalculator.Calculate(
|
||||
LatticeState.StaticReachable, staticResult, null);
|
||||
var defaultConfidence = defaultCalculator.Calculate(
|
||||
LatticeState.StaticReachable, staticResult, null);
|
||||
|
||||
// Assert - both should be in StaticReachable range (0.30-0.49) but custom should be higher
|
||||
// due to larger path count bonus
|
||||
customConfidence.Should().BeGreaterThanOrEqualTo(defaultConfidence);
|
||||
}
|
||||
|
||||
private static StaticReachabilityResult CreateStaticResult(
|
||||
bool isReachable,
|
||||
int pathCount,
|
||||
int guardCount = 0)
|
||||
{
|
||||
var guards = Enumerable.Range(0, guardCount)
|
||||
.Select(i => new GuardCondition { Type = "FeatureFlag", Key = $"flag_{i}" })
|
||||
.ToImmutableArray();
|
||||
|
||||
return new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = isReachable,
|
||||
PathCount = pathCount,
|
||||
Guards = guards,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeReachabilityResult CreateRuntimeResult(
|
||||
bool wasObserved,
|
||||
long hitCount,
|
||||
int environmentCount = 1)
|
||||
{
|
||||
var contexts = Enumerable.Range(0, environmentCount)
|
||||
.Select(i => new ExecutionContext { Environment = $"env_{i}" })
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = wasObserved,
|
||||
HitCount = hitCount,
|
||||
Contexts = contexts,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static SymbolRef CreateTestSymbol() => new()
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// <copyright file="EvidenceUriBuilderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EvidenceUriBuilder"/> and <see cref="EvidenceUri"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EvidenceUriBuilderTests
|
||||
{
|
||||
private readonly EvidenceUriBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphUri("sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://reachgraph/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphUri_Adds_Sha256_Prefix_If_Missing()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphUri("abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://reachgraph/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphSliceUri_Returns_Uri_With_Symbol()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", "symbol_id_123");
|
||||
|
||||
// Assert
|
||||
uri.Should().StartWith("stella://reachgraph/sha256:abc123/slice?symbol=");
|
||||
uri.Should().Contain("symbol_id_123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReachGraphSliceUri_Escapes_SymbolId()
|
||||
{
|
||||
// Arrange
|
||||
var symbolWithSpecialChars = "System.IO/File.Read()";
|
||||
|
||||
// Act
|
||||
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", symbolWithSpecialChars);
|
||||
|
||||
// Assert
|
||||
uri.Should().NotContain("/File"); // Should be escaped
|
||||
uri.Should().Contain(Uri.EscapeDataString(symbolWithSpecialChars));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_Returns_Uri_With_Tenant()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_WithSymbol_Returns_Full_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123", "symbol_id");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123?symbol=symbol_id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeFactsUri_Escapes_TenantId()
|
||||
{
|
||||
// Arrange
|
||||
var tenantWithSpace = "tenant with space";
|
||||
|
||||
// Act
|
||||
var uri = _builder.BuildRuntimeFactsUri(tenantWithSpace, "sha256:abc123");
|
||||
|
||||
// Assert
|
||||
uri.Should().Contain(Uri.EscapeDataString(tenantWithSpace));
|
||||
uri.Should().NotContain(" ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCveMappingUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildCveMappingUri("CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://cvemap/CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAttestationUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildAttestationUri("sha256:attestation_digest");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://attestation/sha256:attestation_digest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHybridResultUri_Returns_Valid_Uri()
|
||||
{
|
||||
// Act
|
||||
var uri = _builder.BuildHybridResultUri("sha256:result_digest");
|
||||
|
||||
// Assert
|
||||
uri.Should().Be("stella://hybrid/sha256:result_digest");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void BuildReachGraphUri_Throws_On_Invalid_Digest(string? digest)
|
||||
{
|
||||
// Act & Assert
|
||||
var act = () => _builder.BuildReachGraphUri(digest!);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// EvidenceUri parsing tests
|
||||
|
||||
[Fact]
|
||||
public void Parse_Valid_ReachGraph_Uri()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Scheme.Should().Be("stella");
|
||||
parsed.Type.Should().Be("reachgraph");
|
||||
parsed.Segments.Should().HaveCount(1);
|
||||
parsed.Segments[0].Should().Be("sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Uri_With_Query_Parameters()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123/slice?symbol=test_symbol");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Type.Should().Be("reachgraph");
|
||||
parsed.Segments.Should().Contain("slice");
|
||||
parsed.Query.Should().ContainKey("symbol");
|
||||
parsed.Query["symbol"].Should().Be("test_symbol");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Uri_With_Multiple_Query_Parameters()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://signals/runtime/tenant?symbol=sym&version=1");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Query.Should().HaveCount(2);
|
||||
parsed.Query["symbol"].Should().Be("sym");
|
||||
parsed.Query["version"].Should().Be("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Decodes_Escaped_Values()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("stella://cvemap/CVE%2D2024%2D1234");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
// Note: path segments aren't automatically decoded in this implementation
|
||||
// but query params are
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://example.com")]
|
||||
[InlineData("stella://")]
|
||||
public void Parse_Returns_Null_For_Invalid_Uris(string? uri)
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse(uri!);
|
||||
|
||||
// Assert
|
||||
parsed.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Is_Case_Insensitive_For_Scheme()
|
||||
{
|
||||
// Act
|
||||
var parsed = EvidenceUri.Parse("STELLA://reachgraph/sha256:abc");
|
||||
|
||||
// Assert
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Type.Should().Be("reachgraph");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// <copyright file="HybridReachabilityResultTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HybridReachabilityResult"/> and related types.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HybridReachabilityResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void ContentDigest_Is_Deterministic()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.Parse("2026-01-09T12:00:00Z");
|
||||
var result1 = CreateResult(now);
|
||||
var result2 = CreateResult(now);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Has_Sha256_Prefix()
|
||||
{
|
||||
// Arrange
|
||||
var result = CreateResult(DateTimeOffset.UtcNow);
|
||||
|
||||
// Act & Assert
|
||||
result.ContentDigest.Should().StartWith("sha256:");
|
||||
result.ContentDigest.Should().HaveLength(7 + 64); // sha256: + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Changes_With_LatticeState()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result1 = CreateResult(now, LatticeState.ConfirmedReachable);
|
||||
var result2 = CreateResult(now, LatticeState.ConfirmedUnreachable);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentDigest_Changes_With_Confidence()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result1 = CreateResult(now, confidence: 0.5);
|
||||
var result2 = CreateResult(now, confidence: 0.9);
|
||||
|
||||
// Act & Assert
|
||||
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_Affected_Has_Correct_Status()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.Affected("Test notes");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("affected");
|
||||
verdict.StatusNotes.Should().Be("Test notes");
|
||||
verdict.Justification.Should().BeNull();
|
||||
verdict.RequiresManualReview.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_NotAffectedUnreachable_Has_Correct_Justification()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.NotAffectedUnreachable("Test notes");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("not_affected");
|
||||
verdict.Justification.Should().Be("vulnerable_code_not_in_execute_path");
|
||||
verdict.StatusNotes.Should().Be("Test notes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_UnderInvestigation_Has_Correct_Status()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.UnderInvestigation("Analyzing...");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("under_investigation");
|
||||
verdict.StatusNotes.Should().Be("Analyzing...");
|
||||
verdict.RequiresManualReview.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictRecommendation_NeedsReview_Sets_ManualReviewFlag()
|
||||
{
|
||||
// Act
|
||||
var verdict = VerdictRecommendation.NeedsReview("Conflict detected");
|
||||
|
||||
// Assert
|
||||
verdict.VexStatus.Should().Be("under_investigation");
|
||||
verdict.RequiresManualReview.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceBundle_Empty_Has_No_Uris()
|
||||
{
|
||||
// Act
|
||||
var bundle = EvidenceBundle.Empty(DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
bundle.Uris.Should().BeEmpty();
|
||||
bundle.Static.Should().BeNull();
|
||||
bundle.Runtime.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticEvidence_Contains_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new StaticEvidence
|
||||
{
|
||||
ReachGraphDigest = "sha256:abc123",
|
||||
SliceUri = "stella://reachgraph/sha256:abc123/slice?symbol=test"
|
||||
};
|
||||
|
||||
// Assert
|
||||
evidence.ReachGraphDigest.Should().Be("sha256:abc123");
|
||||
evidence.SliceUri.Should().Contain("stella://");
|
||||
evidence.CallPath.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeEvidence_Contains_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new RuntimeEvidence
|
||||
{
|
||||
FactsUri = "stella://signals/runtime/tenant/sha256:abc123",
|
||||
ObservationWindow = TimeSpan.FromDays(7)
|
||||
};
|
||||
|
||||
// Assert
|
||||
evidence.FactsUri.Should().Contain("stella://");
|
||||
evidence.ObservationWindow.Should().Be(TimeSpan.FromDays(7));
|
||||
evidence.SampleTraceIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static HybridReachabilityResult CreateResult(
|
||||
DateTimeOffset computedAt,
|
||||
LatticeState state = LatticeState.ConfirmedReachable,
|
||||
double confidence = 0.95)
|
||||
{
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
return new HybridReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
LatticeState = state,
|
||||
Confidence = confidence,
|
||||
Verdict = VerdictRecommendation.Affected(),
|
||||
Evidence = new EvidenceBundle
|
||||
{
|
||||
Uris = ImmutableArray.Create("stella://reachgraph/sha256:abc123"),
|
||||
CollectedAt = computedAt
|
||||
},
|
||||
ComputedAt = computedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// <copyright file="ReachabilityLatticeTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ReachabilityLattice"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReachabilityLatticeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Initial_State_Is_Unknown()
|
||||
{
|
||||
// Arrange & Act
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Assert
|
||||
lattice.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
lattice.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_StaticReachable_TransitionsToStaticReachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.StaticReachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.StaticReachable);
|
||||
lattice.Confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_StaticUnreachable_TransitionsToStaticUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.StaticUnreachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.StaticUnreachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyEvidence_RuntimeObserved_TransitionsToRuntimeObserved()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.RuntimeObserved);
|
||||
lattice.CurrentState.Should().Be(LatticeState.RuntimeObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticReachable_Plus_RuntimeObserved_ConfirmsReachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticUnreachable_Plus_RuntimeUnobserved_ConfirmsUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
|
||||
// Assert
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
lattice.CurrentState.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticUnreachable_Plus_RuntimeObserved_CreatesContest()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert - conflict detected
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeObserved_Plus_StaticUnreachable_CreatesContest()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
|
||||
// Assert - conflict detected
|
||||
transition.Should().NotBeNull();
|
||||
transition!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contested_State_Stays_Contested()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Act - try more evidence
|
||||
var transition1 = lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
var transition2 = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
|
||||
// Assert - stays contested
|
||||
transition1!.ToState.Should().Be(LatticeState.Contested);
|
||||
transition2!.ToState.Should().Be(LatticeState.Contested);
|
||||
lattice.CurrentState.Should().Be(LatticeState.Contested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Confidence_Is_Clamped_To_Valid_Range()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
|
||||
// Act - apply conflicting evidence that might cause negative confidence
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Assert
|
||||
lattice.Confidence.Should().BeGreaterThanOrEqualTo(0.0);
|
||||
lattice.Confidence.Should().BeLessThanOrEqualTo(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Returns_To_Unknown()
|
||||
{
|
||||
// Arrange
|
||||
var lattice = new ReachabilityLattice();
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
|
||||
// Act
|
||||
lattice.Reset();
|
||||
|
||||
// Assert
|
||||
lattice.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
lattice.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticReachable_RuntimeObserved_Returns_ConfirmedReachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = true,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = true,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow,
|
||||
HitCount = 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.ConfirmedReachable);
|
||||
confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticUnreachable_RuntimeUnobserved_Returns_ConfirmedUnreachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = false,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = false,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.ConfirmedUnreachable);
|
||||
confidence.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_NullStatic_RuntimeObserved_Returns_RuntimeObserved()
|
||||
{
|
||||
// Arrange
|
||||
var runtimeResult = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
WasObserved = true,
|
||||
ObservationWindow = TimeSpan.FromDays(7),
|
||||
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
WindowEnd = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, _) = ReachabilityLattice.Combine(null, runtimeResult);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.RuntimeObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_StaticReachable_NullRuntime_Returns_StaticReachable()
|
||||
{
|
||||
// Arrange
|
||||
var staticResult = new StaticReachabilityResult
|
||||
{
|
||||
Symbol = CreateTestSymbol(),
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
IsReachable = true,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var (state, _) = ReachabilityLattice.Combine(staticResult, null);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.StaticReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combine_BothNull_Returns_Unknown()
|
||||
{
|
||||
// Act
|
||||
var (state, confidence) = ReachabilityLattice.Combine(null, null);
|
||||
|
||||
// Assert
|
||||
state.Should().Be(LatticeState.Unknown);
|
||||
confidence.Should().Be(0);
|
||||
}
|
||||
|
||||
private static SymbolRef CreateTestSymbol() => new()
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,212 @@
|
||||
// <copyright file="SymbolRefTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SymbolRef"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SymbolRefTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanonicalId_Is_Deterministic()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.CanonicalId.Should().Be(symbol2.CanonicalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalId_Is_64_Char_Hex()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.CanonicalId.Should().HaveLength(64);
|
||||
symbol.CanonicalId.Should().MatchRegex("^[a-f0-9]+$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Symbols_Have_Different_CanonicalIds()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "set"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.CanonicalId.Should().NotBe(symbol2.CanonicalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_Without_Type_Excludes_Underscore()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.DisplayName.Should().Be("lodash.get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_With_Type_Includes_Type()
|
||||
{
|
||||
// Arrange
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/System.Text.Json@8.0.0",
|
||||
Namespace = "System.Text.Json",
|
||||
Type = "JsonSerializer",
|
||||
Method = "Serialize",
|
||||
Signature = "(object)"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol.DisplayName.Should().Be("System.Text.Json.JsonSerializer.Serialize(object)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_Simple_Name()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "get");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("_");
|
||||
symbol.Type.Should().Be("_");
|
||||
symbol.Method.Should().Be("get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_With_Namespace()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "lodash.get");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("lodash");
|
||||
symbol.Type.Should().Be("_");
|
||||
symbol.Method.Should().Be("get");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Parses_With_Type()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:nuget/System.Text.Json@8.0.0",
|
||||
"System.Text.Json.JsonSerializer.Serialize");
|
||||
|
||||
// Assert
|
||||
symbol.Namespace.Should().Be("System.Text.Json");
|
||||
symbol.Type.Should().Be("JsonSerializer");
|
||||
symbol.Method.Should().Be("Serialize");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Extracts_Signature()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:nuget/System.Text.Json@8.0.0",
|
||||
"System.Text.Json.JsonSerializer.Serialize(object, Type)");
|
||||
|
||||
// Assert
|
||||
symbol.Method.Should().Be("Serialize");
|
||||
symbol.Signature.Should().Be("(object, Type)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFullyQualified_Handles_Empty_Signature()
|
||||
{
|
||||
// Act
|
||||
var symbol = SymbolRef.FromFullyQualified(
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"lodash.get");
|
||||
|
||||
// Assert
|
||||
symbol.Signature.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "test")]
|
||||
[InlineData("", "test")]
|
||||
[InlineData(" ", "test")]
|
||||
[InlineData("pkg:npm/test@1.0.0", null)]
|
||||
[InlineData("pkg:npm/test@1.0.0", "")]
|
||||
[InlineData("pkg:npm/test@1.0.0", " ")]
|
||||
public void FromFullyQualified_Throws_On_Invalid_Input(string? purl, string? name)
|
||||
{
|
||||
// Act & Assert
|
||||
var act = () => SymbolRef.FromFullyQualified(purl!, name!);
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_Works_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var symbol1 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
var symbol2 = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Namespace = "lodash",
|
||||
Type = "_",
|
||||
Method = "get"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
symbol1.Should().Be(symbol2);
|
||||
(symbol1 == symbol2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user