more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -0,0 +1,164 @@
// <copyright file="CallstackEvidenceBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="CallstackEvidenceBuilder"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-011
/// </summary>
[Trait("Category", "Unit")]
public sealed class CallstackEvidenceBuilderTests
{
private readonly CallstackEvidenceBuilder _sut = new();
[Fact]
public void Build_WithReachabilityEvidence_ReturnsCallstackEvidence()
{
// Arrange - builder looks for "reachability", "callgraph", or "call-path" kinds
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "reachability",
Value = "main() -> process() -> vulnerable_fn()",
Source = "static-analysis",
});
var component = CreateComponent(evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
result!.Frames.Should().NotBeEmpty();
}
[Fact]
public void Build_WithNoReachabilityEvidence_ReturnsNull()
{
// Arrange - only non-reachability evidence
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "file" });
var component = CreateComponent(evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().BeNull();
}
[Fact]
public void Build_WithEmptyEvidence_ReturnsNull()
{
// Arrange
var component = CreateComponent();
// Act
var result = _sut.Build(component);
// Assert
result.Should().BeNull();
}
[Fact]
public void Build_WithMultipleReachabilityEvidence_AggregatesFrames()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "callgraph",
Value = "main() -> handler()",
Source = "static-analysis",
},
new ComponentEvidence
{
Kind = "reachability",
Value = "worker() -> process()",
Source = "dynamic-analysis",
});
var component = CreateComponent(evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
result!.Frames.Should().HaveCountGreaterThan(1);
}
[Fact]
public void Build_WithCallPathEvidence_ParsesFrames()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "call-path",
Value = "main() -> lib.process() -> vulnerable_fn()",
Source = "static-analysis",
});
var component = CreateComponent(evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
var frames = result!.Frames.ToList();
// Verify the call path frames are present (implementation may include additional metadata frames)
frames.Should().HaveCountGreaterThanOrEqualTo(3);
frames.Should().Contain(f => f.Function == "main()");
frames.Should().Contain(f => f.Function == "lib.process()");
frames.Should().Contain(f => f.Function == "vulnerable_fn()");
}
[Fact]
public void Build_WithReachabilityAnalysisSource_BuildsFrames()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "reachability",
Value = "entrypoint() -> vulnerable()",
Source = "reachability-analysis",
});
var component = CreateComponent(evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
result!.Frames.Should().NotBeEmpty();
}
private static AggregatedComponent CreateComponent(
string? purl = null,
string? name = null,
ImmutableArray<ComponentEvidence>? evidence = null)
{
var identity = new ComponentIdentity
{
Purl = purl,
Name = name ?? "test-component",
Version = "1.0.0",
Key = Guid.NewGuid().ToString(),
};
return new AggregatedComponent
{
Identity = identity,
Evidence = evidence ?? ImmutableArray<ComponentEvidence>.Empty,
};
}
}

View File

@@ -0,0 +1,258 @@
// <copyright file="CycloneDxEvidenceMapperTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using CycloneDX.Models;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="CycloneDxEvidenceMapper"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-010
/// </summary>
[Trait("Category", "Unit")]
public sealed class CycloneDxEvidenceMapperTests
{
private readonly CycloneDxEvidenceMapper _sut = new();
[Fact]
public void Map_WithIdentityEvidence_MapsToIdentity()
{
// Arrange
var component = CreateComponent(
purl: "pkg:npm/lodash@4.17.21",
evidence: ImmutableArray.Create(
new ComponentEvidence
{
Kind = "manifest",
Value = "package.json",
Source = "/app/package.json",
}));
// Act
var result = _sut.Map(component);
// Assert
result.Should().NotBeNull();
result!.Identity.Should().NotBeNullOrEmpty();
}
[Fact]
public void Map_WithLicenseEvidence_MapsToLicenses()
{
// Arrange
var component = CreateComponent(
purl: "pkg:npm/lodash@4.17.21",
evidence: ImmutableArray.Create(
new ComponentEvidence
{
Kind = "license",
Value = "MIT",
Source = "/app/LICENSE",
}));
// Act
var result = _sut.Map(component);
// Assert
result.Should().NotBeNull();
result!.Licenses.Should().NotBeNullOrEmpty();
}
[Fact]
public void Map_WithCopyrightEvidence_MapsToCopyright()
{
// Arrange
var component = CreateComponent(
purl: "pkg:npm/lodash@4.17.21",
evidence: ImmutableArray.Create(
new ComponentEvidence
{
Kind = "copyright",
Value = "Copyright 2024 StellaOps",
Source = "/app/LICENSE",
}));
// Act
var result = _sut.Map(component);
// Assert
result.Should().NotBeNull();
result!.Copyright.Should().NotBeNullOrEmpty();
result.Copyright![0].Text.Should().Be("Copyright 2024 StellaOps");
}
[Fact]
public void Map_WithNoEvidence_ReturnsIdentityBasedOnPurl()
{
// Arrange - component with PURL but no explicit evidence
var component = CreateComponent(
purl: "pkg:npm/lodash@4.17.21",
evidence: ImmutableArray<ComponentEvidence>.Empty);
// Act
var result = _sut.Map(component);
// Assert - Should return identity evidence based on PURL
// The behavior depends on whether the mapper creates identity from PURL alone
// If PURL is present, identity evidence is generated
result.Should().NotBeNull();
result!.Identity.Should().NotBeNullOrEmpty();
}
[Fact]
public void Map_WithNoPurlAndNoEvidence_ReturnsNull()
{
// Arrange - component without PURL and no evidence
var component = new AggregatedComponent
{
Identity = new ComponentIdentity
{
Name = "unnamed-component",
Version = "1.0.0",
Purl = null,
Key = Guid.NewGuid().ToString(),
},
Evidence = ImmutableArray<ComponentEvidence>.Empty,
};
// Act
var result = _sut.Map(component);
// Assert - Without evidence or PURL, should return null or minimal evidence
// The actual behavior depends on implementation
if (result is not null)
{
// If evidence is returned, it should have minimal data
result.Identity.Should().NotBeNull();
}
}
[Fact]
public void Map_WithMixedEvidence_MapsAllTypes()
{
// Arrange
var component = CreateComponent(
purl: "pkg:npm/lodash@4.17.21",
evidence: ImmutableArray.Create(
new ComponentEvidence
{
Kind = "manifest",
Value = "package.json",
Source = "/app/package.json",
},
new ComponentEvidence
{
Kind = "license",
Value = "MIT",
Source = "/app/LICENSE",
},
new ComponentEvidence
{
Kind = "copyright",
Value = "Copyright 2024",
Source = "/app/LICENSE",
}));
// Act
var result = _sut.Map(component);
// Assert
result.Should().NotBeNull();
result!.Identity.Should().NotBeNullOrEmpty();
result.Licenses.Should().NotBeNullOrEmpty();
result.Copyright.Should().NotBeNullOrEmpty();
}
[Fact]
public void ParseLegacyProperties_WithValidProperties_ReturnsRecords()
{
// Arrange
var properties = new List<Property>
{
new Property
{
Name = "stellaops:evidence[0]",
Value = "crypto:aes-256@/src/crypto.c",
},
new Property
{
Name = "stellaops:evidence[1]",
Value = "license:MIT@/LICENSE",
},
};
// Act
var results = CycloneDxEvidenceMapper.ParseLegacyProperties(properties);
// Assert
results.Should().HaveCount(2);
results[0].Kind.Should().Be("crypto");
results[0].Value.Should().Be("aes-256");
results[0].Source.Should().Be("/src/crypto.c");
}
[Fact]
public void ParseLegacyProperties_WithNullProperties_ReturnsEmpty()
{
// Act
var results = CycloneDxEvidenceMapper.ParseLegacyProperties(null);
// Assert
results.Should().BeEmpty();
}
[Fact]
public void ParseLegacyProperties_WithEmptyProperties_ReturnsEmpty()
{
// Arrange
var properties = new List<Property>();
// Act
var results = CycloneDxEvidenceMapper.ParseLegacyProperties(properties);
// Assert
results.Should().BeEmpty();
}
[Fact]
public void ParseLegacyProperties_WithInvalidFormat_SkipsInvalid()
{
// Arrange
var properties = new List<Property>
{
new Property { Name = "stellaops:evidence[0]", Value = "invalid-format" },
new Property { Name = "stellaops:evidence[1]", Value = "crypto:aes@/file.c" },
new Property { Name = "other:property", Value = "ignored" },
};
// Act
var results = CycloneDxEvidenceMapper.ParseLegacyProperties(properties);
// Assert
results.Should().HaveCount(1);
results[0].Kind.Should().Be("crypto");
}
private static AggregatedComponent CreateComponent(
string purl,
ImmutableArray<ComponentEvidence> evidence)
{
return new AggregatedComponent
{
Identity = new ComponentIdentity
{
Name = "test-component",
Version = "1.0.0",
Purl = purl,
Key = Guid.NewGuid().ToString(),
},
Evidence = evidence,
};
}
}

View File

@@ -0,0 +1,150 @@
// <copyright file="EvidenceConfidenceNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="EvidenceConfidenceNormalizer"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-010
/// </summary>
[Trait("Category", "Unit")]
public sealed class EvidenceConfidenceNormalizerTests
{
[Theory]
[InlineData(0, 0.0)]
[InlineData(50, 0.5)]
[InlineData(100, 1.0)]
[InlineData(75.5, 0.755)]
public void NormalizeFromPercentage_ReturnsCorrectValue(double percentage, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromPercentage(percentage);
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData(-10, 0.0)]
[InlineData(150, 1.0)]
public void NormalizeFromPercentage_ClampsOutOfRangeValues(double percentage, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromPercentage(percentage);
// Assert
result.Should().Be(expected);
}
[Theory]
[InlineData(1, 0.2)]
[InlineData(2, 0.4)]
[InlineData(3, 0.6)]
[InlineData(4, 0.8)]
[InlineData(5, 1.0)]
public void NormalizeFromScale5_ReturnsCorrectValue(int scale, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromScale5(scale);
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData(1, 0.1)]
[InlineData(5, 0.5)]
[InlineData(10, 1.0)]
public void NormalizeFromScale10_ReturnsCorrectValue(int scale, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromScale10(scale);
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData("0.85", "syft", 0.85)]
[InlineData("0.5", "syft", 0.5)]
[InlineData("1.0", "syft", 1.0)]
public void NormalizeFromAnalyzer_Syft_UsesDirect01Scale(string value, string analyzer, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromAnalyzer(value, analyzer);
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData("0.85", "grype", 0.85)] // Grype uses 0.0-1.0 scale like Syft
[InlineData("0.5", "grype", 0.5)]
[InlineData("1.0", "grype", 1.0)]
public void NormalizeFromAnalyzer_Grype_UsesDirect01Scale(string value, string analyzer, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromAnalyzer(value, analyzer);
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData(null, "syft")]
[InlineData("", "syft")]
[InlineData(" ", "syft")]
public void NormalizeFromAnalyzer_NullOrEmpty_ReturnsNull(string? value, string analyzer)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromAnalyzer(value, analyzer);
// Assert
result.Should().BeNull();
}
[Theory]
[InlineData("high", 0.9)]
[InlineData("HIGH", 0.9)]
[InlineData("medium", 0.6)]
[InlineData("low", 0.3)]
public void NormalizeFromAnalyzer_TextualConfidence_ReturnsMapping(string value, double expected)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromAnalyzer(value, "unknown");
// Assert
result.Should().BeApproximately(expected, 0.001);
}
[Theory]
[InlineData("unknown")]
[InlineData("none")]
public void NormalizeFromAnalyzer_UnknownTextualConfidence_ReturnsNull(string value)
{
// Act
var result = EvidenceConfidenceNormalizer.NormalizeFromAnalyzer(value, "unknown");
// Assert - "unknown" means "no confidence data" hence null
result.Should().BeNull();
}
[Theory]
[InlineData(0.75, "0.75")]
[InlineData(0.123456, "0.12")]
[InlineData(1.0, "1.00")]
[InlineData(0.0, "0.00")]
public void FormatConfidence_ReturnsInvariantCultureString(double confidence, string expected)
{
// Act
var result = EvidenceConfidenceNormalizer.FormatConfidence(confidence);
// Assert
result.Should().Be(expected);
}
}

View File

@@ -0,0 +1,189 @@
// <copyright file="IdentityEvidenceBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="IdentityEvidenceBuilder"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-011
/// </summary>
[Trait("Category", "Unit")]
public sealed class IdentityEvidenceBuilderTests
{
private readonly IdentityEvidenceBuilder _sut = new();
[Fact]
public void Build_WithPurl_ReturnsFieldAsPurl()
{
// Arrange
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21");
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
result!.Field.Should().Be("purl");
}
[Fact]
public void Build_WithNameOnly_ReturnsFieldAsName()
{
// Arrange
var component = CreateComponent(name: "my-package");
// Act
var result = _sut.Build(component);
// Assert
result.Should().NotBeNull();
result!.Field.Should().Be("name");
}
[Fact]
public void Build_WithManifestEvidence_IncludesManifestAnalysisMethod()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "/app/package.json" });
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21", evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result!.Methods.Should().ContainSingle(m =>
m.Technique == IdentityEvidenceTechnique.ManifestAnalysis);
result.Methods![0].Confidence.Should().Be(0.95);
}
[Fact]
public void Build_WithBinaryEvidence_IncludesBinaryAnalysisMethod()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "binary", Value = "lodash.dll", Source = "/app/lodash.dll" });
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21", evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result!.Methods.Should().ContainSingle(m =>
m.Technique == IdentityEvidenceTechnique.BinaryAnalysis);
result.Methods![0].Confidence.Should().Be(0.80);
}
[Fact]
public void Build_WithHashEvidence_IncludesHighConfidenceMethod()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "hash", Value = "sha256:abc123", Source = "/app/lib.so" });
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21", evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result!.Methods.Should().ContainSingle(m =>
m.Technique == IdentityEvidenceTechnique.HashComparison);
result.Methods![0].Confidence.Should().Be(0.99);
}
[Fact]
public void Build_WithMultipleSources_IncludesAllMethods()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "/app/package.json" },
new ComponentEvidence { Kind = "binary", Value = "lib.dll", Source = "/app/lib.dll" },
new ComponentEvidence { Kind = "hash", Value = "sha256:abc", Source = "/app/lib.so" });
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21", evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result!.Methods.Should().HaveCount(3);
}
[Fact]
public void Build_CalculatesOverallConfidenceFromHighestMethod()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "binary", Value = "lib.dll", Source = "/app/lib.dll" },
new ComponentEvidence { Kind = "hash", Value = "sha256:abc", Source = "/app/lib.so" });
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21", evidence: evidence);
// Act
var result = _sut.Build(component);
// Assert
result!.Confidence.Should().Be(0.99); // Hash match has highest confidence
}
[Fact]
public void Build_WithNoIdentifyingData_ReturnsNull()
{
// Arrange - unknown name means no identification
var component = new AggregatedComponent
{
Identity = ComponentIdentity.Create("unknown", "unknown"),
};
// Act
var result = _sut.Build(component);
// Assert
result.Should().BeNull();
}
[Fact]
public void Build_WithPurlNoEvidence_ReturnsAttestationMethod()
{
// Arrange
var component = CreateComponent(purl: "pkg:npm/lodash@4.17.21");
// Act
var result = _sut.Build(component);
// Assert
result!.Methods.Should().ContainSingle(m =>
m.Technique == IdentityEvidenceTechnique.Attestation);
result.Methods![0].Confidence.Should().Be(0.70);
}
[Fact]
public void Build_NullComponent_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => _sut.Build(null!);
act.Should().Throw<ArgumentNullException>();
}
private static AggregatedComponent CreateComponent(
string? purl = null,
string? name = null,
ImmutableArray<ComponentEvidence> evidence = default)
{
var identity = ComponentIdentity.Create(
key: purl ?? name ?? "unknown",
name: name ?? "test-component",
purl: purl);
return new AggregatedComponent
{
Identity = identity,
Evidence = evidence.IsDefault ? ImmutableArray<ComponentEvidence>.Empty : evidence,
};
}
}

View File

@@ -0,0 +1,172 @@
// <copyright file="LegacyEvidencePropertyWriterTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using CycloneDX.Models;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="LegacyEvidencePropertyWriter"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-010
/// </summary>
[Trait("Category", "Unit")]
public sealed class LegacyEvidencePropertyWriterTests
{
private readonly LegacyEvidencePropertyWriter _sut = new();
[Fact]
public void WriteEvidenceProperties_WithEvidence_AddsProperties()
{
// Arrange
var component = new Component { Name = "test-component" };
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "manifest",
Value = "package.json",
Source = "/app/package.json",
});
var options = new LegacyEvidenceOptions();
// Act
_sut.WriteEvidenceProperties(component, evidence, options);
// Assert
component.Properties.Should().NotBeEmpty();
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[0]:kind");
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[0]:value");
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[0]:source");
}
[Fact]
public void WriteEvidenceProperties_WithEmptyEvidence_DoesNotAddProperties()
{
// Arrange
var component = new Component { Name = "test-component" };
var evidence = ImmutableArray<ComponentEvidence>.Empty;
var options = new LegacyEvidenceOptions();
// Act
_sut.WriteEvidenceProperties(component, evidence, options);
// Assert
component.Properties.Should().BeNullOrEmpty();
}
[Fact]
public void WriteEvidenceProperties_WithMultipleEvidence_AddsIndexedProperties()
{
// Arrange
var component = new Component { Name = "test-component" };
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "file" },
new ComponentEvidence { Kind = "binary", Value = "lib.dll", Source = "binary-scan" });
var options = new LegacyEvidenceOptions();
// Act
_sut.WriteEvidenceProperties(component, evidence, options);
// Assert
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[0]:kind");
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[1]:kind");
}
[Fact]
public void WriteEvidenceProperties_WithNullComponent_ThrowsArgumentNullException()
{
// Arrange
Component component = null!;
var evidence = ImmutableArray<ComponentEvidence>.Empty;
var options = new LegacyEvidenceOptions();
// Act
var act = () => _sut.WriteEvidenceProperties(component, evidence, options);
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void WriteEvidenceProperties_PreservesExistingProperties()
{
// Arrange
var component = new Component
{
Name = "test-component",
Properties = [new Property { Name = "existing", Value = "value" }],
};
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "file" });
var options = new LegacyEvidenceOptions();
// Act
_sut.WriteEvidenceProperties(component, evidence, options);
// Assert
component.Properties.Should().Contain(p => p.Name == "existing");
component.Properties.Should().Contain(p => p.Name == "stellaops:evidence[0]:kind");
}
[Fact]
public void WriteEvidenceProperties_WithMethodsReference_IncludesMethodsProperty()
{
// Arrange
var component = new Component { Name = "test-component" };
var evidence = ImmutableArray.Create(
new ComponentEvidence
{
Kind = "identity",
Value = "pkg:npm/lodash@4.17.21",
Source = "manifest-analysis",
});
var options = new LegacyEvidenceOptions { IncludeMethodsReference = true };
// Act
_sut.WriteEvidenceProperties(component, evidence, options);
// Assert
component.Properties.Should().Contain(p => p.Name!.StartsWith("stellaops:evidence"));
}
[Fact]
public void RemoveLegacyProperties_RemovesAllEvidenceProperties()
{
// Arrange
var component = new Component
{
Name = "test-component",
Properties =
[
new Property { Name = "stellaops:evidence[0]:kind", Value = "manifest" },
new Property { Name = "stellaops:evidence[0]:value", Value = "package.json" },
new Property { Name = "other:property", Value = "preserved" },
],
};
// Act
_sut.RemoveLegacyProperties(component);
// Assert
component.Properties.Should().HaveCount(1);
component.Properties.Should().Contain(p => p.Name == "other:property");
}
[Fact]
public void RemoveLegacyProperties_WithNullProperties_DoesNotThrow()
{
// Arrange
var component = new Component { Name = "test-component", Properties = null };
// Act
var act = () => _sut.RemoveLegacyProperties(component);
// Assert
act.Should().NotThrow();
}
}

View File

@@ -0,0 +1,141 @@
// <copyright file="LicenseEvidenceBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="LicenseEvidenceBuilder"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-011
/// </summary>
[Trait("Category", "Unit")]
public sealed class LicenseEvidenceBuilderTests
{
private readonly LicenseEvidenceBuilder _sut = new();
[Fact]
public void Build_WithLicenseEvidence_ReturnsLicenseChoices()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "license", Value = "MIT", Source = "/app/LICENSE" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
result[0].License.Should().NotBeNull();
result[0].License!.License.Should().NotBeNull();
result[0].License!.License!.Id.Should().Be("MIT");
}
[Fact]
public void Build_WithMultipleLicenses_ReturnsAllLicenses()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "license", Value = "MIT", Source = "/app/LICENSE" },
new ComponentEvidence { Kind = "license", Value = "Apache-2.0", Source = "/app/LICENSE.apache" },
new ComponentEvidence { Kind = "license", Value = "GPL-3.0", Source = "/app/COPYING" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(3);
}
[Fact]
public void Build_WithNonLicenseEvidence_FiltersOutNonLicenses()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "license", Value = "MIT", Source = "/app/LICENSE" },
new ComponentEvidence { Kind = "file", Value = "readme.md", Source = "/app/README.md" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
result[0].License!.License!.Id.Should().Be("MIT");
}
[Fact]
public void Build_WithNoEvidence_ReturnsEmptyArray()
{
// Arrange
var component = CreateComponent(ImmutableArray<ComponentEvidence>.Empty);
// Act
var result = _sut.Build(component);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void Build_NullComponent_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => _sut.Build(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Build_WithExpression_ParsesAsExpression()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "license", Value = "MIT OR Apache-2.0", Source = "/app/LICENSE" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
// SPDX expressions are parsed as expression rather than ID
result[0].License.Expression.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public void Build_DeduplicatesSameLicense()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "license", Value = "MIT", Source = "/app/LICENSE" },
new ComponentEvidence { Kind = "license", Value = "MIT", Source = "/app/package.json" }); // Same license
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
}
private static AggregatedComponent CreateComponent(ImmutableArray<ComponentEvidence> evidence)
{
var identity = ComponentIdentity.Create(
key: "pkg:npm/lodash@4.17.21",
name: "lodash",
purl: "pkg:npm/lodash@4.17.21");
return new AggregatedComponent
{
Identity = identity,
Evidence = evidence,
};
}
}

View File

@@ -0,0 +1,123 @@
// <copyright file="OccurrenceEvidenceBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Evidence;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Evidence;
/// <summary>
/// Unit tests for <see cref="OccurrenceEvidenceBuilder"/>.
/// Sprint: SPRINT_20260107_005_001 Task EV-011
/// </summary>
[Trait("Category", "Unit")]
public sealed class OccurrenceEvidenceBuilderTests
{
private readonly OccurrenceEvidenceBuilder _sut = new();
[Fact]
public void Build_WithFileEvidence_ReturnsOccurrences()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "file", Value = "lodash.min.js", Source = "/app/node_modules/lodash/lodash.min.js" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
result[0].Location.Should().Be("/app/node_modules/lodash/lodash.min.js");
}
[Fact]
public void Build_WithMultipleFiles_ReturnsAllOccurrences()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "file", Value = "lodash.js", Source = "/app/src/lodash.js" },
new ComponentEvidence { Kind = "file", Value = "lodash.min.js", Source = "/app/dist/lodash.min.js" },
new ComponentEvidence { Kind = "file", Value = "lodash.core.js", Source = "/app/lib/lodash.core.js" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(3);
}
[Fact]
public void Build_WithMixedEvidenceKinds_IncludesAllWithSource()
{
// Arrange - OccurrenceBuilder captures all evidence with source locations
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "file", Value = "lodash.js", Source = "/app/src/lodash.js" },
new ComponentEvidence { Kind = "manifest", Value = "package.json", Source = "/app/package.json" });
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert - All evidence types with sources are captured as occurrences
result.Should().HaveCount(2);
result.Should().Contain(o => o.Location == "/app/src/lodash.js");
result.Should().Contain(o => o.Location == "/app/package.json");
}
[Fact]
public void Build_WithNoEvidence_ReturnsEmptyArray()
{
// Arrange
var component = CreateComponent(ImmutableArray<ComponentEvidence>.Empty);
// Act
var result = _sut.Build(component);
// Assert
result.Should().BeEmpty();
}
[Fact]
public void Build_NullComponent_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => _sut.Build(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Build_DeduplicatesSameLocation()
{
// Arrange
var evidence = ImmutableArray.Create(
new ComponentEvidence { Kind = "file", Value = "lodash.js", Source = "/app/lodash.js" },
new ComponentEvidence { Kind = "file", Value = "lodash.js", Source = "/app/lodash.js" }); // Duplicate
var component = CreateComponent(evidence);
// Act
var result = _sut.Build(component);
// Assert
result.Should().HaveCount(1);
}
private static AggregatedComponent CreateComponent(ImmutableArray<ComponentEvidence> evidence)
{
var identity = ComponentIdentity.Create(
key: "pkg:npm/lodash@4.17.21",
name: "lodash",
purl: "pkg:npm/lodash@4.17.21");
return new AggregatedComponent
{
Identity = identity,
Evidence = evidence,
};
}
}