doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,176 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Cache.LayerCache;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Cache.Tests.LayerCache;
/// <summary>
/// Unit tests for <see cref="LayerCacheStore"/> with FakeTimeProvider for deterministic TTL testing.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class LayerCacheStoreTimeProviderTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly LayerCacheStore _store;
private readonly string _testCacheDir;
public LayerCacheStoreTimeProviderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
_testCacheDir = Path.Combine(Path.GetTempPath(), $"layer-cache-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testCacheDir);
var options = Options.Create(new ScannerCacheOptions
{
RootPath = _testCacheDir,
LayerTtl = TimeSpan.FromHours(24)
});
_store = new LayerCacheStore(
options,
NullLogger<LayerCacheStore>.Instance,
_timeProvider);
}
public void Dispose()
{
if (Directory.Exists(_testCacheDir))
{
try
{
Directory.Delete(_testCacheDir, recursive: true);
}
catch
{
// Ignore cleanup errors in tests
}
}
}
private static LayerCachePutRequest CreatePutRequest(string layerDigest) =>
new(
layerDigest: layerDigest,
architecture: "amd64",
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
metadata: new Dictionary<string, string>(),
artifacts: new List<LayerCacheArtifactContent>
{
new("sbom.json", new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }), "application/json")
});
[Fact]
public async Task TryGetAsync_NewEntry_ReturnsEntry()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
await _store.PutAsync(request);
// Act
var entry = await _store.TryGetAsync(layerDigest);
// Assert
entry.Should().NotBeNull();
entry!.LayerDigest.Should().Be(layerDigest);
}
[Fact]
public async Task TryGetAsync_AfterTtlExpires_ReturnsNull()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
await _store.PutAsync(request);
// Verify entry exists
var entryBefore = await _store.TryGetAsync(layerDigest);
entryBefore.Should().NotBeNull();
// Act - advance time past TTL (24 hours + buffer)
_timeProvider.Advance(TimeSpan.FromHours(25));
var entryAfter = await _store.TryGetAsync(layerDigest);
// Assert
entryAfter.Should().BeNull();
}
[Fact]
public async Task TryGetAsync_BeforeTtlExpires_ReturnsEntry()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
await _store.PutAsync(request);
// Act - advance time but not past TTL
_timeProvider.Advance(TimeSpan.FromHours(12));
var entry = await _store.TryGetAsync(layerDigest);
// Assert
entry.Should().NotBeNull();
entry!.LayerDigest.Should().Be(layerDigest);
}
[Fact]
public async Task TryGetAsync_AtExactTtl_ReturnsNull()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
await _store.PutAsync(request);
// Act - advance time to exactly TTL + 1 second
_timeProvider.Advance(TimeSpan.FromHours(24).Add(TimeSpan.FromSeconds(1)));
var entry = await _store.TryGetAsync(layerDigest);
// Assert
entry.Should().BeNull();
}
[Fact]
public async Task TryGetAsync_UpdatesLastAccessed()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
await _store.PutAsync(request);
// First access
await _store.TryGetAsync(layerDigest);
var firstAccessTime = _timeProvider.GetUtcNow();
// Advance time
_timeProvider.Advance(TimeSpan.FromHours(1));
// Second access
var entry = await _store.TryGetAsync(layerDigest);
// Assert
entry.Should().NotBeNull();
entry!.LastAccessed.Should().BeAfter(firstAccessTime);
}
[Fact]
public async Task PutAsync_SetsCorrectCachedAt()
{
// Arrange
var layerDigest = "sha256:" + Guid.NewGuid().ToString("N");
var request = CreatePutRequest(layerDigest);
var expectedTime = _timeProvider.GetUtcNow();
// Act
var entry = await _store.PutAsync(request);
// Assert
entry.CachedAt.Should().Be(expectedTime);
}
}

View File

@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />

View File

@@ -0,0 +1,361 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Normalization;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Core.Tests.Normalization;
public class PackageNameNormalizerTests
{
private readonly PackageNameNormalizer _normalizer;
public PackageNameNormalizerTests()
{
var options = Options.Create(new PackageNameNormalizerOptions
{
EnableCaching = true,
EnableFileHashFallback = true
});
_normalizer = new PackageNameNormalizer(
options,
NullLogger<PackageNameNormalizer>.Instance);
}
#region PURL Parsing Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_ValidPurl_ReturnsParsedResult()
{
var result = await _normalizer.NormalizeAsync("pkg:npm/lodash@4.17.21");
result.CanonicalPurl.Should().Be("pkg:npm/lodash@4.17.21");
result.Ecosystem.Should().Be("npm");
result.Name.Should().Be("lodash");
result.Version.Should().Be("4.17.21");
result.Confidence.Should().BeGreaterThan(0.9);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_ScopedNpmPackage_NormalizesCorrectly()
{
var result = await _normalizer.NormalizeAsync("pkg:npm/@types/node@18.0.0");
result.Ecosystem.Should().Be("npm");
result.Namespace.Should().Be("types");
result.Name.Should().Be("node");
result.Version.Should().Be("18.0.0");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_PurlWithoutVersion_ParsesCorrectly()
{
var result = await _normalizer.NormalizeAsync("pkg:pypi/requests");
result.CanonicalPurl.Should().Be("pkg:pypi/requests");
result.Ecosystem.Should().Be("pypi");
result.Name.Should().Be("requests");
result.Version.Should().BeNull();
}
#endregion
#region Ecosystem Normalization Tests
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("pkg:npm/Lodash@4.17.21", "pkg:npm/lodash@4.17.21")]
[InlineData("pkg:npm/REACT@18.0.0", "pkg:npm/react@18.0.0")]
public async Task NormalizeAsync_NpmPackage_LowercasesName(string input, string expected)
{
var result = await _normalizer.NormalizeAsync(input);
result.CanonicalPurl.Should().Be(expected);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("pkg:pypi/Flask_RESTful@0.3.9", "pkg:pypi/flask-restful@0.3.9")]
[InlineData("pkg:pypi/Django.REST.Framework@3.14.0", "pkg:pypi/django-rest-framework@3.14.0")]
[InlineData("pkg:pypi/Scikit_Learn@1.0.0", "pkg:pypi/scikit-learn@1.0.0")]
public async Task NormalizeAsync_PypiPackage_NormalizesNameCorrectly(string input, string expected)
{
var result = await _normalizer.NormalizeAsync(input);
result.CanonicalPurl.Should().Be(expected);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_DebianPackage_SetsDefaultNamespace()
{
var result = await _normalizer.NormalizeAsync("pkg:deb/libc6");
result.Namespace.Should().Be("debian");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_GolangPackage_NormalizesModulePath()
{
var result = await _normalizer.NormalizeAsync("pkg:golang/github.com/Sirupsen/Logrus@1.9.0");
result.Name.Should().Be("github.com/sirupsen/logrus");
}
#endregion
#region Alias Lookup Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_KnownAlias_ReturnsCanonical()
{
// PIL is an alias for pillow
var result = await _normalizer.NormalizeAsync("PIL");
result.CanonicalPurl.Should().Contain("pillow");
result.Method.Should().Be(NormalizationMethod.AliasLookup);
result.Confidence.Should().Be(1.0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_SklearnAlias_NormalizesToScikitLearn()
{
var result = await _normalizer.NormalizeAsync("sklearn");
result.CanonicalPurl.Should().Contain("scikit-learn");
result.Method.Should().Be(NormalizationMethod.AliasLookup);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAliasesAsync_KnownPackage_ReturnsAliases()
{
var aliases = await _normalizer.GetAliasesAsync("pkg:pypi/pillow");
aliases.Should().NotBeEmpty();
aliases.Should().Contain("PIL");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetAliasesAsync_UnknownPackage_ReturnsEmpty()
{
var aliases = await _normalizer.GetAliasesAsync("pkg:npm/unknown-package-xyz");
aliases.Should().BeEmpty();
}
#endregion
#region Equivalence Tests
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("pkg:npm/lodash@4.17.20", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/Lodash@4.17.20", "pkg:npm/lodash@4.17.21", true)]
[InlineData("pkg:npm/lodash@4.17.20", "pkg:npm/underscore@1.13.6", false)]
public async Task AreEquivalentAsync_PackagesWithDifferentVersions_ComparesIdentity(
string pkg1, string pkg2, bool expected)
{
var result = await _normalizer.AreEquivalentAsync(pkg1, pkg2);
result.Should().Be(expected);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AreEquivalentAsync_AliasAndCanonical_ReturnsTrue()
{
// PIL and pillow should be equivalent
var result = await _normalizer.AreEquivalentAsync("PIL", "pkg:pypi/pillow@9.0.0");
result.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AreEquivalentAsync_DifferentPackages_ReturnsFalse()
{
var result = await _normalizer.AreEquivalentAsync("pkg:npm/react@18.0.0", "pkg:npm/vue@3.0.0");
result.Should().BeFalse();
}
#endregion
#region Non-PURL Parsing Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_NpmStyleReference_ParsesCorrectly()
{
var result = await _normalizer.NormalizeAsync("@types/node@18.0.0");
result.Ecosystem.Should().Be("npm");
result.Namespace.Should().Be("types");
result.Name.Should().Be("node");
result.Version.Should().Be("18.0.0");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_PipStyleReference_ParsesCorrectly()
{
var result = await _normalizer.NormalizeAsync("requests==2.28.0");
result.Ecosystem.Should().Be("pypi");
result.Name.Should().Be("requests");
result.Version.Should().Be("2.28.0");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_DebianStyleReference_ParsesCorrectly()
{
var result = await _normalizer.NormalizeAsync("libssl3=3.0.8-1");
result.Ecosystem.Should().Be("deb");
result.Name.Should().Be("libssl3");
result.Version.Should().Be("3.0.8-1");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_GoModulePath_ParsesCorrectly()
{
var result = await _normalizer.NormalizeAsync("github.com/gin-gonic/gin@v1.9.0");
result.Ecosystem.Should().Be("golang");
result.Name.Should().Contain("gin-gonic/gin");
result.Version.Should().Be("v1.9.0");
}
#endregion
#region Fallback Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_UnknownFormat_ReturnsPassthrough()
{
var result = await _normalizer.NormalizeAsync("some-unknown-reference");
result.Method.Should().Be(NormalizationMethod.Passthrough);
result.Confidence.Should().BeLessThan(0.5);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_FilePathReference_UsesFileHashFallback()
{
var result = await _normalizer.NormalizeAsync("/usr/lib/libcustom.so");
result.Method.Should().Be(NormalizationMethod.FileHashFallback);
result.FileHash.Should().NotBeNullOrEmpty();
}
#endregion
#region False Positive Prevention Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AreEquivalentAsync_SimilarButDifferentPackages_ReturnsFalse()
{
// react and react-dom are different packages, even though related
var result = await _normalizer.AreEquivalentAsync(
"pkg:npm/react@18.0.0",
"pkg:npm/react-dom@18.0.0");
result.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AreEquivalentAsync_SimilarNamesAcrossEcosystems_ReturnsFalse()
{
// requests in npm vs pypi are different packages
var result = await _normalizer.AreEquivalentAsync(
"pkg:npm/requests@1.0.0",
"pkg:pypi/requests@2.28.0");
result.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("lodash", "underscore")]
[InlineData("express", "fastify")]
[InlineData("react", "vue")]
public async Task AreEquivalentAsync_SimilarLibraries_ReturnsFalse(string pkg1, string pkg2)
{
var result = await _normalizer.AreEquivalentAsync(
$"pkg:npm/{pkg1}@1.0.0",
$"pkg:npm/{pkg2}@1.0.0");
result.Should().BeFalse();
}
#endregion
#region Caching Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_SameInput_ReturnsCachedResult()
{
var result1 = await _normalizer.NormalizeAsync("pkg:npm/lodash@4.17.21");
var result2 = await _normalizer.NormalizeAsync("pkg:npm/lodash@4.17.21");
// Results should be identical (potentially same reference if cached)
result1.CanonicalPurl.Should().Be(result2.CanonicalPurl);
result1.Confidence.Should().Be(result2.Confidence);
}
#endregion
#region Determinism Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task NormalizeAsync_SameInput_ProducesDeterministicOutput()
{
var inputs = new[]
{
"pkg:npm/lodash@4.17.21",
"pkg:pypi/requests@2.28.0",
"pkg:deb/debian/libc6@2.31-13",
"PIL",
"sklearn"
};
var results1 = new List<NormalizedPackageIdentity>();
var results2 = new List<NormalizedPackageIdentity>();
foreach (var input in inputs)
{
results1.Add(await _normalizer.NormalizeAsync(input));
}
foreach (var input in inputs)
{
results2.Add(await _normalizer.NormalizeAsync(input));
}
for (var i = 0; i < inputs.Length; i++)
{
results1[i].CanonicalPurl.Should().Be(results2[i].CanonicalPurl);
results1[i].Method.Should().Be(results2[i].Method);
results1[i].Confidence.Should().Be(results2[i].Confidence);
}
}
#endregion
}

View File

@@ -16,6 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,207 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.Core.TrustAnchors;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
/// <summary>
/// Unit tests for <see cref="TrustAnchorRegistry"/> with FakeTimeProvider for deterministic expiration testing.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class TrustAnchorRegistryTimeProviderTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly DateTimeOffset _fixedNow;
public TrustAnchorRegistryTimeProviderTests()
{
_fixedNow = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero);
_timeProvider = new FakeTimeProvider(_fixedNow);
}
[Fact]
public void ResolveForPurl_WithExpiredAnchor_SkipsIt()
{
// Arrange
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "expired",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
ExpiresAt = _fixedNow.AddDays(-1) // Already expired
},
new()
{
AnchorId = "active",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
["bbbb"] = new byte[] { 0x03, 0x04 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
_timeProvider);
// Act
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
// Assert
resolution.Should().NotBeNull();
resolution!.AnchorId.Should().Be("active");
}
[Fact]
public void ResolveForPurl_WithFutureExpirationAnchor_UsesIt()
{
// Arrange
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "not-yet-expired",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
ExpiresAt = _fixedNow.AddDays(1) // Expires tomorrow
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
_timeProvider);
// Act
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
// Assert
resolution.Should().NotBeNull();
resolution!.AnchorId.Should().Be("not-yet-expired");
}
[Fact]
public void ResolveForPurl_AfterTimeAdvances_PreviouslyValidAnchorExpires()
{
// Arrange
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "short-lived",
PurlPattern = "pkg:npm/*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
ExpiresAt = _fixedNow.AddHours(1) // Expires in 1 hour
},
new()
{
AnchorId = "fallback",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
["bbbb"] = new byte[] { 0x03, 0x04 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
_timeProvider);
// Act - before expiration
var resolutionBefore = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
resolutionBefore.Should().NotBeNull();
resolutionBefore!.AnchorId.Should().Be("short-lived");
// Advance time past expiration
_timeProvider.Advance(TimeSpan.FromHours(2));
// Act - after expiration
var resolutionAfter = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
// Assert
resolutionAfter.Should().NotBeNull();
resolutionAfter!.AnchorId.Should().Be("fallback");
}
[Fact]
public void ResolveForPurl_ExactExpirationTime_TreatsAsExpired()
{
// Arrange
var options = new OfflineKitOptions
{
Enabled = true,
TrustAnchors = new List<TrustAnchorConfig>
{
new()
{
AnchorId = "expires-now",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:aaaa" },
ExpiresAt = _fixedNow // Expires exactly now
},
new()
{
AnchorId = "fallback",
PurlPattern = "*",
AllowedKeyIds = new List<string> { "sha256:bbbb" },
}
}
};
var keys = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{
["aaaa"] = new byte[] { 0x01, 0x02 },
["bbbb"] = new byte[] { 0x03, 0x04 },
};
var registry = new TrustAnchorRegistry(
new StaticOptionsMonitor<OfflineKitOptions>(options),
new StubKeyLoader(keys),
NullLogger<TrustAnchorRegistry>.Instance,
_timeProvider);
// Act
var resolution = registry.ResolveForPurl("pkg:npm/foo@1.0.0");
// Assert - should use fallback since exactly-now is treated as expired
resolution.Should().NotBeNull();
resolution!.AnchorId.Should().Be("fallback");
}
}

View File

@@ -349,4 +349,222 @@ public class SbomDiffEngineTests
}
#endregion
#region Layer Attribution Tests
private static LayerSbomInput CreateLayerInput(string diffId, int layerIndex, params ComponentRef[] components)
{
return new LayerSbomInput
{
DiffId = diffId,
LayerIndex = layerIndex,
Components = components.ToList()
};
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_AddedComponent_AttributesToCorrectLayer()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0")),
CreateLayerInput("sha256:app", 1, CreateComponent("app-pkg", "2.0.0"))
};
var diff = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff.BaseDiff.Summary.Added.Should().Be(1);
diff.ChangesByLayer.Should().HaveCount(1);
diff.ChangesByLayer[0].DiffId.Should().Be("sha256:app");
diff.ChangesByLayer[0].LayerIndex.Should().Be(1);
diff.ChangesByLayer[0].Changes.Should().HaveCount(1);
diff.ChangesByLayer[0].Changes[0].Type.Should().Be(ComponentDeltaType.Added);
diff.ChangesByLayer[0].Changes[0].SourceLayerDiffId.Should().Be("sha256:app");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_RemovedComponent_AttributesToOldLayer()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0")),
CreateLayerInput("sha256:app", 1, CreateComponent("app-pkg", "2.0.0"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0"))
};
var diff = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff.BaseDiff.Summary.Removed.Should().Be(1);
var removedDelta = diff.BaseDiff.Deltas.First(d => d.Type == ComponentDeltaType.Removed);
removedDelta.SourceLayerDiffId.Should().Be("sha256:app");
removedDelta.SourceLayerIndex.Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_VersionChange_AttributesToNewLayer()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base-old", 0, CreateComponent("lodash", "4.17.20"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base-new", 0, CreateComponent("lodash", "4.17.21"))
};
var diff = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff.BaseDiff.Summary.VersionChanged.Should().Be(1);
var versionDelta = diff.BaseDiff.Deltas.First(d => d.Type == ComponentDeltaType.VersionChanged);
versionDelta.SourceLayerDiffId.Should().Be("sha256:base-new");
versionDelta.SourceLayerIndex.Should().Be(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_MultipleLayersWithChanges_GroupsCorrectly()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base", 0,
CreateComponent("base-pkg", "1.0.0"),
CreateComponent("removed-from-base", "1.0.0"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base-new", 0,
CreateComponent("base-pkg", "1.0.0"),
CreateComponent("new-in-base", "1.0.0")),
CreateLayerInput("sha256:app", 1,
CreateComponent("app-pkg", "2.0.0"))
};
var diff = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff.ChangesByLayer.Should().HaveCount(2);
var baseChanges = diff.ChangesByLayer.First(g => g.DiffId == "sha256:base-new");
baseChanges.Changes.Should().Contain(c => c.After!.Name == "new-in-base");
var appChanges = diff.ChangesByLayer.First(g => g.DiffId == "sha256:app");
appChanges.Changes.Should().Contain(c => c.After!.Name == "app-pkg");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_LayerSummaries_CalculatedCorrectly()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0")),
CreateLayerInput("sha256:app", 1,
CreateComponent("app-pkg1", "1.0.0"),
CreateComponent("app-pkg2", "2.0.0"))
};
var diff = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff.LayerSummaries.Should().HaveCount(2);
var baseSummary = diff.LayerSummaries.First(s => s.DiffId == "sha256:base");
baseSummary.TotalComponents.Should().Be(1);
baseSummary.Added.Should().Be(0);
var appSummary = diff.LayerSummaries.First(s => s.DiffId == "sha256:app");
appSummary.TotalComponents.Should().Be(2);
appSummary.Added.Should().Be(2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FindComponentLayer_ComponentExists_ReturnsCorrectLayer()
{
var layers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0")),
CreateLayerInput("sha256:app", 1, CreateComponent("target-pkg", "2.0.0"))
};
var result = SbomDiffEngine.FindComponentLayer(layers, "pkg:npm/target-pkg@2.0.0");
result.Should().Be("sha256:app");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FindComponentLayer_ComponentNotFound_ReturnsNull()
{
var layers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("base-pkg", "1.0.0"))
};
var result = SbomDiffEngine.FindComponentLayer(layers, "pkg:npm/nonexistent@1.0.0");
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeLayerAttributedDiff_Determinism_SameInputsProduceSameOutput()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("pkg1", "1.0.0")),
CreateLayerInput("sha256:mid", 1, CreateComponent("pkg2", "1.0.0"))
};
var toLayers = new[]
{
CreateLayerInput("sha256:base", 0, CreateComponent("pkg1", "1.0.0")),
CreateLayerInput("sha256:mid-new", 1, CreateComponent("pkg2", "2.0.0")),
CreateLayerInput("sha256:app", 2, CreateComponent("pkg3", "1.0.0"))
};
var diff1 = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
var diff2 = _engine.ComputeLayerAttributedDiff(fromId, fromLayers, toId, toLayers);
diff1.BaseDiff.Summary.Should().BeEquivalentTo(diff2.BaseDiff.Summary);
diff1.ChangesByLayer.Length.Should().Be(diff2.ChangesByLayer.Length);
diff1.LayerSummaries.Length.Should().Be(diff2.LayerSummaries.Length);
}
#endregion
}

View File

@@ -0,0 +1,155 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Reachability.Slices;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
/// <summary>
/// Unit tests for <see cref="InMemorySliceCache"/> with proper timer testing using FakeTimeProvider.
/// </summary>
[Trait("Category", "Unit")]
public sealed class InMemorySliceCacheTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemorySliceCache _cache;
public InMemorySliceCacheTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
_cache = new InMemorySliceCache(
NullLogger<InMemorySliceCache>.Instance,
_timeProvider);
}
public void Dispose()
{
_cache.Dispose();
}
[Fact]
public async Task TryGetAsync_WithValidEntry_ReturnsValue()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().NotBeNull();
retrieved!.SliceDigest.Should().Be("digest1");
}
[Fact]
public async Task TryGetAsync_WithExpiredEntry_ReturnsNull()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromMinutes(30));
// Advance time past expiration
_timeProvider.Advance(TimeSpan.FromMinutes(31));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().BeNull();
}
[Fact]
public async Task TryGetAsync_BeforeExpiration_ReturnsValue()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Advance time but not past expiration
_timeProvider.Advance(TimeSpan.FromMinutes(59));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().NotBeNull();
}
[Fact]
public async Task SetAsync_OverwritesExistingEntry()
{
// Arrange
var result1 = CreateTestResult("digest1");
var result2 = CreateTestResult("digest2");
await _cache.SetAsync("key1", result1, TimeSpan.FromHours(1));
// Act
await _cache.SetAsync("key1", result2, TimeSpan.FromHours(1));
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().NotBeNull();
retrieved!.SliceDigest.Should().Be("digest2");
}
[Fact]
public async Task TryGetAsync_NonExistentKey_ReturnsNull()
{
// Act
var retrieved = await _cache.TryGetAsync("nonexistent");
// Assert
retrieved.Should().BeNull();
}
[Fact]
public async Task GetStatistics_ReturnsAccurateStats()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Act - hit
await _cache.TryGetAsync("key1");
// Act - miss
await _cache.TryGetAsync("nonexistent");
var stats = _cache.GetStatistics();
// Assert
stats.HitCount.Should().Be(1);
stats.MissCount.Should().Be(1);
stats.EntryCount.Should().Be(1);
}
[Fact]
public async Task EvictionTimer_AdvanceTime_EvictsExpiredEntries()
{
// Arrange - add entries with short TTL
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromSeconds(30));
// Advance time to trigger eviction timer (60 seconds interval)
_timeProvider.Advance(TimeSpan.FromSeconds(61));
// Allow the timer callback to complete
await Task.Delay(50);
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert - should be evicted
retrieved.Should().BeNull();
}
private static CachedSliceResult CreateTestResult(string digest) => new()
{
SliceDigest = digest,
Verdict = "safe",
Confidence = 0.95,
PathWitnesses = new List<string> { "path1", "path2" },
CachedAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,174 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Reachability.Slices;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Slices;
/// <summary>
/// Unit tests for <see cref="SliceCache"/> with proper timer testing using FakeTimeProvider.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SliceCacheTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly SliceCache _cache;
public SliceCacheTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new SliceCacheOptions
{
Enabled = true,
Ttl = TimeSpan.FromHours(1),
MaxItems = 100
});
_cache = new SliceCache(options, _timeProvider);
}
public void Dispose()
{
_cache.Dispose();
}
[Fact]
public async Task TryGetAsync_WithValidEntry_ReturnsValue()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().NotBeNull();
retrieved!.SliceDigest.Should().Be("digest1");
}
[Fact]
public async Task TryGetAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var options = Options.Create(new SliceCacheOptions { Enabled = false });
using var cache = new SliceCache(options, _timeProvider);
var result = CreateTestResult("digest1");
await cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Act
var retrieved = await cache.TryGetAsync("key1");
// Assert
retrieved.Should().BeNull();
}
[Fact]
public async Task TryGetAsync_WithExpiredEntry_ReturnsNull()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromMinutes(30));
// Advance time past expiration
_timeProvider.Advance(TimeSpan.FromMinutes(31));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().BeNull();
}
[Fact]
public async Task TryGetAsync_BeforeExpiration_ReturnsValue()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Advance time but not past expiration
_timeProvider.Advance(TimeSpan.FromMinutes(59));
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert
retrieved.Should().NotBeNull();
}
[Fact]
public async Task SetAsync_ExceedsMaxItems_EvictsOldest()
{
// Arrange - create cache with small max
var options = Options.Create(new SliceCacheOptions
{
Enabled = true,
MaxItems = 10,
Ttl = TimeSpan.FromHours(1)
});
using var cache = new SliceCache(options, _timeProvider);
// Add items up to max
for (int i = 0; i < 10; i++)
{
await cache.SetAsync($"key{i}", CreateTestResult($"digest{i}"), TimeSpan.FromHours(1));
_timeProvider.Advance(TimeSpan.FromSeconds(1)); // Ensure different access times
}
// Act - add one more, should trigger eviction
await cache.SetAsync("key_new", CreateTestResult("digest_new"), TimeSpan.FromHours(1));
// Assert - new item should be present
var retrieved = await cache.TryGetAsync("key_new");
retrieved.Should().NotBeNull();
}
[Fact]
public async Task EvictionTimer_AdvanceTime_EvictsExpiredEntries()
{
// Arrange - add entry with short TTL
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromSeconds(30));
// Advance time past eviction timer interval (1 minute)
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
// Allow timer callback to complete
await Task.Delay(50);
// Act
var retrieved = await _cache.TryGetAsync("key1");
// Assert - should be evicted
retrieved.Should().BeNull();
}
[Fact]
public async Task GetStatistics_ReturnsAccurateHitMissCount()
{
// Arrange
var result = CreateTestResult("digest1");
await _cache.SetAsync("key1", result, TimeSpan.FromHours(1));
// Act
await _cache.TryGetAsync("key1"); // hit
await _cache.TryGetAsync("key1"); // hit
await _cache.TryGetAsync("nonexistent"); // miss
var stats = _cache.GetStatistics();
// Assert
stats.HitCount.Should().Be(2);
stats.MissCount.Should().Be(1);
}
private static CachedSliceResult CreateTestResult(string digest) => new()
{
SliceDigest = digest,
Verdict = "safe",
Confidence = 0.95,
PathWitnesses = new List<string> { "path1", "path2" },
CachedAt = DateTimeOffset.UtcNow
};
}

View File

@@ -11,8 +11,9 @@
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit.v3" />
<PackageReference Include="JsonSchema.Net" />
<PackageReference Include="Moq" />
</ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />