Initial commit (history squashed)
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Feedser.Merge.Options;
|
||||
using StellaOps.Feedser.Merge.Services;
|
||||
using StellaOps.Feedser.Models;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class AdvisoryPrecedenceMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_PrefersVendorPrecedenceOverNvd()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var expectedMergeTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, redHat });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Red Hat Security Advisory", merged.Title);
|
||||
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
|
||||
Assert.Equal("high", merged.Severity);
|
||||
Assert.Equal(redHat.Published, merged.Published);
|
||||
Assert.Equal(redHat.Modified, merged.Modified);
|
||||
Assert.Contains("RHSA-2025:0001", merged.Aliases);
|
||||
Assert.Contains("CVE-2025-1000", merged.Aliases);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
||||
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
|
||||
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
|
||||
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
|
||||
|
||||
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_KevOnlyTogglesExploitKnown()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
|
||||
var baseAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"CVE-2025-2000",
|
||||
"Base registry summary",
|
||||
"en",
|
||||
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 1, 6, 0, 0, 0, TimeSpan.Zero),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-2000" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
|
||||
null,
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"semver",
|
||||
"2.0.0",
|
||||
"2.0.5",
|
||||
null,
|
||||
"<2.0.5",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance })
|
||||
},
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
|
||||
var kevAdvisory = new Advisory(
|
||||
"CVE-2025-2000",
|
||||
"Known Exploited Vulnerability",
|
||||
summary: null,
|
||||
language: null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-2000" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { kevProvenance });
|
||||
|
||||
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory });
|
||||
|
||||
Assert.True(merged.ExploitKnown);
|
||||
Assert.Equal("medium", merged.Severity); // KEV must not override severity
|
||||
Assert.Equal("Base registry summary", merged.Summary);
|
||||
Assert.Contains("CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "kev");
|
||||
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_RespectsConfiguredPrecedenceOverrides()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var options = new AdvisoryPrecedenceOptions
|
||||
{
|
||||
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["nvd"] = 0,
|
||||
["redhat"] = 5,
|
||||
}
|
||||
};
|
||||
|
||||
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
|
||||
using var metrics = new MetricCollector("StellaOps.Feedser.Merge");
|
||||
|
||||
var merger = new AdvisoryPrecedenceMerger(
|
||||
new AffectedPackagePrecedenceResolver(),
|
||||
options,
|
||||
timeProvider,
|
||||
logger);
|
||||
|
||||
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
||||
var merged = merger.Merge(new[] { redHat, nvd });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
|
||||
Assert.Equal("NVD summary", merged.Summary);
|
||||
Assert.Equal("medium", merged.Severity);
|
||||
|
||||
var package = Assert.Single(merged.AffectedPackages);
|
||||
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
|
||||
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "feedser.merge.overrides");
|
||||
Assert.Equal(1, overrideMeasurement.Value);
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
|
||||
Assert.Equal(LogLevel.Information, logEntry.Level);
|
||||
Assert.NotNull(logEntry.StructuredState);
|
||||
Assert.Contains(logEntry.StructuredState!, kvp =>
|
||||
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
|
||||
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
|
||||
kvp.Value is not null);
|
||||
}
|
||||
|
||||
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
|
||||
{
|
||||
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
|
||||
var redHatModified = redHatPublished.AddDays(1);
|
||||
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
|
||||
var redHatPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
|
||||
new[] { redHatProvenance });
|
||||
var redHat = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Red Hat Security Advisory",
|
||||
"Vendor-confirmed impact on RHEL 9.",
|
||||
"en",
|
||||
redHatPublished,
|
||||
redHatModified,
|
||||
"high",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000", "RHSA-2025:0001" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://access.redhat.com/errata/RHSA-2025:0001",
|
||||
"advisory",
|
||||
"redhat",
|
||||
"Red Hat errata",
|
||||
redHatProvenance)
|
||||
},
|
||||
affectedPackages: new[] { redHatPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
9.8,
|
||||
"critical",
|
||||
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
|
||||
},
|
||||
provenance: new[] { redHatProvenance });
|
||||
|
||||
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
|
||||
var nvdModified = nvdPublished.AddDays(2);
|
||||
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
|
||||
var nvdPackage = new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
"rhel-9",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
"cpe",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"<=9.0",
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { nvdProvenance });
|
||||
var nvd = new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
"NVD summary",
|
||||
"en",
|
||||
nvdPublished,
|
||||
nvdModified,
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference(
|
||||
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
|
||||
"advisory",
|
||||
"nvd",
|
||||
"NVD advisory",
|
||||
nvdProvenance)
|
||||
},
|
||||
affectedPackages: new[] { nvdPackage },
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric(
|
||||
"3.1",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
|
||||
6.8,
|
||||
"medium",
|
||||
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
|
||||
},
|
||||
provenance: new[] { nvdProvenance });
|
||||
|
||||
return (redHat, nvd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using StellaOps.Feedser.Merge.Services;
|
||||
using StellaOps.Feedser.Models;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class AffectedPackagePrecedenceResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_PrefersRedHatOverNvdForSameCpe()
|
||||
{
|
||||
var redHat = new AffectedPackage(
|
||||
type: AffectedPackageTypes.Cpe,
|
||||
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
platform: "RHEL 9",
|
||||
versionRanges: Array.Empty<AffectedVersionRange>(),
|
||||
statuses: new[]
|
||||
{
|
||||
new AffectedPackageStatus(
|
||||
status: "known_affected",
|
||||
provenance: new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z")))
|
||||
},
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z"))
|
||||
});
|
||||
|
||||
var nvd = new AffectedPackage(
|
||||
type: AffectedPackageTypes.Cpe,
|
||||
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
||||
platform: "RHEL 9",
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "cpe",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "<=9.0",
|
||||
provenance: new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z")))
|
||||
},
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z"))
|
||||
});
|
||||
|
||||
var resolver = new AffectedPackagePrecedenceResolver();
|
||||
var merged = resolver.Merge(new[] { nvd, redHat });
|
||||
|
||||
var package = Assert.Single(merged);
|
||||
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
||||
Assert.Empty(package.VersionRanges); // NVD range overridden
|
||||
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
||||
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_KeepsNvdWhenNoHigherPrecedence()
|
||||
{
|
||||
var nvd = new AffectedPackage(
|
||||
type: AffectedPackageTypes.Cpe,
|
||||
identifier: "cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "semver",
|
||||
introducedVersion: null,
|
||||
fixedVersion: "1.0.1",
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "<1.0.1",
|
||||
provenance: new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z")))
|
||||
},
|
||||
provenance: new[]
|
||||
{
|
||||
new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z"))
|
||||
});
|
||||
|
||||
var resolver = new AffectedPackagePrecedenceResolver();
|
||||
var merged = resolver.Merge(new[] { nvd });
|
||||
|
||||
var package = Assert.Single(merged);
|
||||
Assert.Equal(nvd.Identifier, package.Identifier);
|
||||
Assert.Equal(nvd.VersionRanges.Single().RangeExpression, package.VersionRanges.Single().RangeExpression);
|
||||
Assert.Equal("nvd", package.Provenance.Single().Source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Feedser.Merge.Services;
|
||||
using StellaOps.Feedser.Models;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class CanonicalHashCalculatorTests
|
||||
{
|
||||
private static readonly Advisory SampleAdvisory = new(
|
||||
advisoryKey: "CVE-2024-0001",
|
||||
title: "Sample advisory",
|
||||
summary: "A sample summary",
|
||||
language: "EN",
|
||||
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
|
||||
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
|
||||
severity: "high",
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "GHSA-xyz", "CVE-2024-0001" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://example.com/advisory", "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty),
|
||||
new AdvisoryReference("https://example.com/blog", "article", "blog", summary: null, provenance: AdvisoryProvenance.Empty),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
type: AffectedPackageTypes.SemVer,
|
||||
identifier: "pkg:npm/sample@1.0.0",
|
||||
platform: null,
|
||||
versionRanges: new[]
|
||||
{
|
||||
new AffectedVersionRange("semver", "1.0.0", "1.2.0", null, null, AdvisoryProvenance.Empty),
|
||||
new AffectedVersionRange("semver", "1.2.0", null, null, null, AdvisoryProvenance.Empty),
|
||||
},
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: new[] { AdvisoryProvenance.Empty })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", AdvisoryProvenance.Empty)
|
||||
},
|
||||
provenance: new[] { AdvisoryProvenance.Empty });
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_ReturnsDeterministicValue()
|
||||
{
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
var first = calculator.ComputeHash(SampleAdvisory);
|
||||
var second = calculator.ComputeHash(SampleAdvisory);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_IgnoresOrderingDifferences()
|
||||
{
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
|
||||
var reordered = new Advisory(
|
||||
SampleAdvisory.AdvisoryKey,
|
||||
SampleAdvisory.Title,
|
||||
SampleAdvisory.Summary,
|
||||
SampleAdvisory.Language,
|
||||
SampleAdvisory.Published,
|
||||
SampleAdvisory.Modified,
|
||||
SampleAdvisory.Severity,
|
||||
SampleAdvisory.ExploitKnown,
|
||||
aliases: SampleAdvisory.Aliases.Reverse().ToArray(),
|
||||
references: SampleAdvisory.References.Reverse().ToArray(),
|
||||
affectedPackages: SampleAdvisory.AffectedPackages.Reverse().ToArray(),
|
||||
cvssMetrics: SampleAdvisory.CvssMetrics.Reverse().ToArray(),
|
||||
provenance: SampleAdvisory.Provenance.Reverse().ToArray());
|
||||
|
||||
var originalHash = calculator.ComputeHash(SampleAdvisory);
|
||||
var reorderedHash = calculator.ComputeHash(reordered);
|
||||
|
||||
Assert.Equal(originalHash, reorderedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NullReturnsEmpty()
|
||||
{
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
Assert.Empty(calculator.ComputeHash(null));
|
||||
}
|
||||
}
|
||||
84
src/StellaOps.Feedser.Merge.Tests/DebianEvrComparerTests.cs
Normal file
84
src/StellaOps.Feedser.Merge.Tests/DebianEvrComparerTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using StellaOps.Feedser.Merge.Comparers;
|
||||
using StellaOps.Feedser.Normalization.Distro;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class DebianEvrComparerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1:1.2.3-1", 1, "1.2.3", "1")]
|
||||
[InlineData("1.2.3-1", 0, "1.2.3", "1")]
|
||||
[InlineData("2:4.5", 2, "4.5", "")]
|
||||
[InlineData("abc", 0, "abc", "")]
|
||||
public void TryParse_ReturnsComponents(string input, int expectedEpoch, string expectedVersion, string expectedRevision)
|
||||
{
|
||||
var success = DebianEvr.TryParse(input, out var evr);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(evr);
|
||||
Assert.Equal(expectedEpoch, evr!.Epoch);
|
||||
Assert.Equal(expectedVersion, evr.Version);
|
||||
Assert.Equal(expectedRevision, evr.Revision);
|
||||
Assert.Equal(input, evr.Original);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(":1.0-1")]
|
||||
[InlineData("1:")]
|
||||
public void TryParse_InvalidInputs_ReturnFalse(string input)
|
||||
{
|
||||
var success = DebianEvr.TryParse(input, out var evr);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(evr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_PrefersHigherEpoch()
|
||||
{
|
||||
var lower = "0:2.0-1";
|
||||
var higher = "1:1.0-1";
|
||||
|
||||
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_UsesVersionOrdering()
|
||||
{
|
||||
var lower = "0:1.2.3-1";
|
||||
var higher = "0:1.10.0-1";
|
||||
|
||||
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_TildeRanksEarlier()
|
||||
{
|
||||
var prerelease = "0:1.0~beta1-1";
|
||||
var stable = "0:1.0-1";
|
||||
|
||||
Assert.True(DebianEvrComparer.Instance.Compare(prerelease, stable) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_RevisionBreaksTies()
|
||||
{
|
||||
var first = "0:1.0-1";
|
||||
var second = "0:1.0-2";
|
||||
|
||||
Assert.True(DebianEvrComparer.Instance.Compare(second, first) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FallsBackToOrdinalForInvalid()
|
||||
{
|
||||
var left = "not-an-evr";
|
||||
var right = "also-not";
|
||||
|
||||
var expected = Math.Sign(string.CompareOrdinal(left, right));
|
||||
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
85
src/StellaOps.Feedser.Merge.Tests/MergeEventWriterTests.cs
Normal file
85
src/StellaOps.Feedser.Merge.Tests/MergeEventWriterTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Feedser.Merge.Services;
|
||||
using StellaOps.Feedser.Models;
|
||||
using StellaOps.Feedser.Storage.Mongo.MergeEvents;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class MergeEventWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AppendAsync_WritesRecordWithComputedHashes()
|
||||
{
|
||||
var store = new InMemoryMergeEventStore();
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
|
||||
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
|
||||
var before = CreateAdvisory("CVE-2024-0001", "Initial");
|
||||
var after = CreateAdvisory("CVE-2024-0001", "Sample", summary: "Updated");
|
||||
|
||||
var documentIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
|
||||
var record = await writer.AppendAsync("CVE-2024-0001", before, after, documentIds, CancellationToken.None);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, record.Id);
|
||||
Assert.Equal("CVE-2024-0001", record.AdvisoryKey);
|
||||
Assert.True(record.AfterHash.Length > 0);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), record.MergedAt);
|
||||
Assert.Equal(documentIds, record.InputDocumentIds);
|
||||
Assert.NotNull(store.LastRecord);
|
||||
Assert.Same(store.LastRecord, record);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendAsync_NullBeforeUsesEmptyHash()
|
||||
{
|
||||
var store = new InMemoryMergeEventStore();
|
||||
var calculator = new CanonicalHashCalculator();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
|
||||
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
|
||||
var after = CreateAdvisory("CVE-2024-0002", "Changed");
|
||||
|
||||
var record = await writer.AppendAsync("CVE-2024-0002", null, after, Array.Empty<Guid>(), CancellationToken.None);
|
||||
|
||||
Assert.Empty(record.BeforeHash);
|
||||
Assert.True(record.AfterHash.Length > 0);
|
||||
}
|
||||
|
||||
|
||||
private static Advisory CreateAdvisory(string advisoryKey, string title, string? summary = null)
|
||||
{
|
||||
return new Advisory(
|
||||
advisoryKey,
|
||||
title,
|
||||
summary,
|
||||
language: "en",
|
||||
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
|
||||
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
|
||||
severity: "medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { advisoryKey },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://example.com/" + advisoryKey.ToLowerInvariant(), "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty)
|
||||
},
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: Array.Empty<AdvisoryProvenance>());
|
||||
}
|
||||
|
||||
private sealed class InMemoryMergeEventStore : IMergeEventStore
|
||||
{
|
||||
public MergeEventRecord? LastRecord { get; private set; }
|
||||
|
||||
public Task AppendAsync(MergeEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRecord = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Feedser.Merge.Services;
|
||||
using StellaOps.Feedser.Models;
|
||||
using StellaOps.Feedser.Storage.Mongo;
|
||||
using StellaOps.Feedser.Storage.Mongo.MergeEvents;
|
||||
using StellaOps.Feedser.Testing;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private MergeEventStore? _mergeEventStore;
|
||||
private MergeEventWriter? _mergeEventWriter;
|
||||
private AdvisoryPrecedenceMerger? _merger;
|
||||
private FakeTimeProvider? _timeProvider;
|
||||
|
||||
public MergePrecedenceIntegrationTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePipeline_PsirtOverridesNvd_AndKevOnlyTogglesExploitKnown()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
var merger = _merger!;
|
||||
var writer = _mergeEventWriter!;
|
||||
var store = _mergeEventStore!;
|
||||
var timeProvider = _timeProvider!;
|
||||
|
||||
var expectedTimestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var nvd = CreateNvdBaseline();
|
||||
var vendor = CreateVendorOverride();
|
||||
var kev = CreateKevSignal();
|
||||
|
||||
var merged = merger.Merge(new[] { nvd, vendor, kev });
|
||||
|
||||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||||
Assert.Equal("Vendor Security Advisory", merged.Title);
|
||||
Assert.Equal("Critical impact on supported platforms.", merged.Summary);
|
||||
Assert.Equal("critical", merged.Severity);
|
||||
Assert.True(merged.ExploitKnown);
|
||||
|
||||
var affected = Assert.Single(merged.AffectedPackages);
|
||||
Assert.Empty(affected.VersionRanges);
|
||||
Assert.Contains(affected.Statuses, status => status.Status == "known_affected" && status.Provenance.Source == "vendor");
|
||||
|
||||
var mergeProvenance = Assert.Single(merged.Provenance, p => p.Source == "merge");
|
||||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||||
Assert.Equal(expectedTimestamp, mergeProvenance.RecordedAt);
|
||||
Assert.Contains("vendor", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("kev", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var inputDocumentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
var record = await writer.AppendAsync(merged.AdvisoryKey, nvd, merged, inputDocumentIds, CancellationToken.None);
|
||||
|
||||
Assert.Equal(expectedTimestamp, record.MergedAt);
|
||||
Assert.Equal(inputDocumentIds, record.InputDocumentIds);
|
||||
Assert.NotEqual(record.BeforeHash, record.AfterHash);
|
||||
|
||||
var records = await store.GetRecentAsync(merged.AdvisoryKey, 5, CancellationToken.None);
|
||||
var persisted = Assert.Single(records);
|
||||
Assert.Equal(record.Id, persisted.Id);
|
||||
Assert.Equal(merged.AdvisoryKey, persisted.AdvisoryKey);
|
||||
Assert.True(persisted.AfterHash.Length > 0);
|
||||
Assert.True(persisted.BeforeHash.Length > 0);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero))
|
||||
{
|
||||
AutoAdvanceAmount = TimeSpan.Zero,
|
||||
};
|
||||
_merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), _timeProvider);
|
||||
_mergeEventStore = new MergeEventStore(_fixture.Database, NullLogger<MergeEventStore>.Instance);
|
||||
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||
await DropMergeCollectionAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_mergeEventWriter is null)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DropMergeCollectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.MergeEvent);
|
||||
}
|
||||
catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Collection has not been created yet – safe to ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CreateNvdBaseline()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", DateTimeOffset.Parse("2025-02-10T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"CVE-2025-1000",
|
||||
"Baseline description from NVD.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-05T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-10T12:00:00Z"),
|
||||
"medium",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-1000", "advisory", "nvd", "NVD reference", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "cpe",
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: "<=1.0",
|
||||
provenance: provenance)
|
||||
},
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateVendorOverride()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-1000", DateTimeOffset.Parse("2025-02-11T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Vendor Security Advisory",
|
||||
"Critical impact on supported platforms.",
|
||||
"en",
|
||||
DateTimeOffset.Parse("2025-02-06T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-02-11T06:00:00Z"),
|
||||
"critical",
|
||||
exploitKnown: false,
|
||||
aliases: new[] { "CVE-2025-1000", "VSA-2025-1000" },
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReference("https://vendor.example/advisories/VSA-2025-1000", "advisory", "vendor", "Vendor advisory", provenance),
|
||||
},
|
||||
affectedPackages: new[]
|
||||
{
|
||||
new AffectedPackage(
|
||||
AffectedPackageTypes.Cpe,
|
||||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||||
"vendor-os",
|
||||
Array.Empty<AffectedVersionRange>(),
|
||||
new[]
|
||||
{
|
||||
new AffectedPackageStatus("known_affected", provenance)
|
||||
},
|
||||
new[] { provenance })
|
||||
},
|
||||
cvssMetrics: new[]
|
||||
{
|
||||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "critical", provenance)
|
||||
},
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
|
||||
private static Advisory CreateKevSignal()
|
||||
{
|
||||
var provenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-1000", DateTimeOffset.Parse("2025-02-12T00:00:00Z"));
|
||||
return new Advisory(
|
||||
"CVE-2025-1000",
|
||||
"Known Exploited Vulnerability",
|
||||
null,
|
||||
null,
|
||||
published: null,
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: true,
|
||||
aliases: new[] { "KEV-CVE-2025-1000" },
|
||||
references: Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { provenance });
|
||||
}
|
||||
}
|
||||
56
src/StellaOps.Feedser.Merge.Tests/MetricCollector.cs
Normal file
56
src/StellaOps.Feedser.Merge.Tests/MetricCollector.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
internal sealed class MetricCollector : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<MetricMeasurement> _measurements = new();
|
||||
|
||||
public MetricCollector(string meterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(meterName))
|
||||
{
|
||||
throw new ArgumentException("Meter name is required", nameof(meterName));
|
||||
}
|
||||
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == meterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
var tagArray = new KeyValuePair<string, object?>[tags.Length];
|
||||
for (var i = 0; i < tags.Length; i++)
|
||||
{
|
||||
tagArray[i] = tags[i];
|
||||
}
|
||||
|
||||
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagArray));
|
||||
});
|
||||
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public IReadOnlyList<MetricMeasurement> Measurements => _measurements;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_listener.Dispose();
|
||||
}
|
||||
|
||||
internal sealed record MetricMeasurement(
|
||||
string Name,
|
||||
long Value,
|
||||
IReadOnlyList<KeyValuePair<string, object?>> Tags);
|
||||
}
|
||||
108
src/StellaOps.Feedser.Merge.Tests/NevraComparerTests.cs
Normal file
108
src/StellaOps.Feedser.Merge.Tests/NevraComparerTests.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using StellaOps.Feedser.Merge.Comparers;
|
||||
using StellaOps.Feedser.Normalization.Distro;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class NevraComparerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("kernel-1:4.18.0-348.7.1.el8_5.x86_64", "kernel", 1, "4.18.0", "348.7.1.el8_5", "x86_64")]
|
||||
[InlineData("bash-5.1.8-2.fc35.x86_64", "bash", 0, "5.1.8", "2.fc35", "x86_64")]
|
||||
[InlineData("openssl-libs-1:1.1.1k-7.el8", "openssl-libs", 1, "1.1.1k", "7.el8", null)]
|
||||
[InlineData("java-11-openjdk-1:11.0.23.0.9-2.el9_4.ppc64le", "java-11-openjdk", 1, "11.0.23.0.9", "2.el9_4", "ppc64le")]
|
||||
[InlineData("bash-0:5.2.15-3.el9_4.arm64", "bash", 0, "5.2.15", "3.el9_4", "arm64")]
|
||||
[InlineData("podman-3:4.9.3-1.el9.x86_64", "podman", 3, "4.9.3", "1.el9", "x86_64")]
|
||||
public void TryParse_ReturnsExpectedComponents(string input, string expectedName, int expectedEpoch, string expectedVersion, string expectedRelease, string? expectedArch)
|
||||
{
|
||||
var success = Nevra.TryParse(input, out var nevra);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(nevra);
|
||||
Assert.Equal(expectedName, nevra!.Name);
|
||||
Assert.Equal(expectedEpoch, nevra.Epoch);
|
||||
Assert.Equal(expectedVersion, nevra.Version);
|
||||
Assert.Equal(expectedRelease, nevra.Release);
|
||||
Assert.Equal(expectedArch, nevra.Architecture);
|
||||
Assert.Equal(input, nevra.Original);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("kernel4.18.0-80.el8")]
|
||||
[InlineData("kernel-4.18.0")]
|
||||
public void TryParse_InvalidInputs_ReturnFalse(string input)
|
||||
{
|
||||
var success = Nevra.TryParse(input, out var nevra);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(nevra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TrimsWhitespace()
|
||||
{
|
||||
var success = Nevra.TryParse(" kernel-0:4.18.0-80.el8.x86_64 ", out var nevra);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(nevra);
|
||||
Assert.Equal("kernel", nevra!.Name);
|
||||
Assert.Equal("4.18.0", nevra.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_PrefersHigherEpoch()
|
||||
{
|
||||
var older = "kernel-0:4.18.0-348.7.1.el8_5.x86_64";
|
||||
var newer = "kernel-1:4.18.0-348.7.1.el8_5.x86_64";
|
||||
|
||||
Assert.True(NevraComparer.Instance.Compare(newer, older) > 0);
|
||||
Assert.True(NevraComparer.Instance.Compare(older, newer) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_UsesRpmVersionOrdering()
|
||||
{
|
||||
var lower = "kernel-0:4.18.0-80.el8.x86_64";
|
||||
var higher = "kernel-0:4.18.11-80.el8.x86_64";
|
||||
|
||||
Assert.True(NevraComparer.Instance.Compare(higher, lower) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_UsesReleaseOrdering()
|
||||
{
|
||||
var el8 = "bash-0:5.1.0-1.el8.x86_64";
|
||||
var el9 = "bash-0:5.1.0-1.el9.x86_64";
|
||||
|
||||
Assert.True(NevraComparer.Instance.Compare(el9, el8) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_TildeRanksEarlier()
|
||||
{
|
||||
var prerelease = "bash-0:5.1.0~beta-1.fc34.x86_64";
|
||||
var stable = "bash-0:5.1.0-1.fc34.x86_64";
|
||||
|
||||
Assert.True(NevraComparer.Instance.Compare(prerelease, stable) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ConsidersArchitecture()
|
||||
{
|
||||
var noarch = "pkg-0:1.0-1.noarch";
|
||||
var arch = "pkg-0:1.0-1.x86_64";
|
||||
|
||||
Assert.True(NevraComparer.Instance.Compare(noarch, arch) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_FallsBackToOrdinalForInvalid()
|
||||
{
|
||||
var left = "not-a-nevra";
|
||||
var right = "also-not";
|
||||
|
||||
var expected = Math.Sign(string.CompareOrdinal(left, right));
|
||||
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using StellaOps.Feedser.Merge.Comparers;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
public sealed class SemanticVersionRangeResolverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.2.3", true)]
|
||||
[InlineData("1.2.3-beta.1", true)]
|
||||
[InlineData("invalid", false)]
|
||||
[InlineData(null, false)]
|
||||
public void TryParse_ReturnsExpected(string? input, bool expected)
|
||||
{
|
||||
var success = SemanticVersionRangeResolver.TryParse(input, out var version);
|
||||
|
||||
Assert.Equal(expected, success);
|
||||
Assert.Equal(expected, version is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ParsesSemanticVersions()
|
||||
{
|
||||
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3", "1.2.2") > 0);
|
||||
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3-beta", "1.2.3") < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_UsesOrdinalFallbackForInvalid()
|
||||
{
|
||||
var left = "zzz";
|
||||
var right = "aaa";
|
||||
var expected = Math.Sign(string.CompareOrdinal(left, right));
|
||||
var actual = Math.Sign(SemanticVersionRangeResolver.Compare(left, right));
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWindows_WithFixedVersion_ComputesExclusiveUpper()
|
||||
{
|
||||
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", "1.2.0", null);
|
||||
|
||||
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
|
||||
Assert.Equal(SemanticVersionRangeResolver.Parse("1.2.0"), exclusive);
|
||||
Assert.Null(inclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWindows_WithLastAffectedOnly_ComputesInclusiveAndExclusive()
|
||||
{
|
||||
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", null, "1.1.5");
|
||||
|
||||
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
|
||||
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.6"), exclusive);
|
||||
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.5"), inclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWindows_WithNeither_ReturnsNullBounds()
|
||||
{
|
||||
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows(null, null, null);
|
||||
|
||||
Assert.Null(introduced);
|
||||
Assert.Null(exclusive);
|
||||
Assert.Null(inclusive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Feedser.Merge/StellaOps.Feedser.Merge.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Feedser.Normalization/StellaOps.Feedser.Normalization.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Feedser.Storage.Mongo/StellaOps.Feedser.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
52
src/StellaOps.Feedser.Merge.Tests/TestLogger.cs
Normal file
52
src/StellaOps.Feedser.Merge.Tests/TestLogger.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Feedser.Merge.Tests;
|
||||
|
||||
internal sealed class TestLogger<T> : ILogger<T>
|
||||
{
|
||||
private static readonly IDisposable NoopScope = new DisposableScope();
|
||||
|
||||
public List<LogEntry> Entries { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
=> NoopScope;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (formatter is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(formatter));
|
||||
}
|
||||
|
||||
IReadOnlyList<KeyValuePair<string, object?>>? structuredState = null;
|
||||
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
|
||||
{
|
||||
structuredState = list.ToArray();
|
||||
}
|
||||
else if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
|
||||
{
|
||||
structuredState = enumerable.ToArray();
|
||||
}
|
||||
|
||||
Entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception), structuredState));
|
||||
}
|
||||
|
||||
internal sealed record LogEntry(
|
||||
LogLevel Level,
|
||||
EventId EventId,
|
||||
string Message,
|
||||
IReadOnlyList<KeyValuePair<string, object?>>? StructuredState);
|
||||
|
||||
private sealed class DisposableScope : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user