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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user