Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,92 @@
using System;
using System.Linq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryIdentityResolverTests
{
private readonly AdvisoryIdentityResolver _resolver = new();
[Fact]
public void Resolve_GroupsBySharedCveAlias()
{
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
var clusters = _resolver.Resolve(new[] { nvd, vendor });
var cluster = Assert.Single(clusters);
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
}
[Fact]
public void Resolve_PrefersPsirtAliasWhenNoCve()
{
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
var clusters = _resolver.Resolve(new[] { vendor, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
}
[Fact]
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
{
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
var clusters = _resolver.Resolve(new[] { ghsa, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
}
[Fact]
public void Resolve_GroupsByKeyWhenNoAliases()
{
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
var clusters = _resolver.Resolve(new[] { first, second });
var cluster = Assert.Single(clusters);
Assert.Equal("custom-1", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
}
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
{
var provenance = new[]
{
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
};
return new Advisory(
key,
$"{key} title",
$"{key} summary",
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
exploitKnown: false,
aliases,
Array.Empty<AdvisoryCredit>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
provenance);
}
}

View File

@@ -0,0 +1,326 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Driver;
using StellaOps.Concelier.Core;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryMergeServiceTests
{
[Fact]
public async Task MergeAsync_AppliesCanonicalRulesAndPersistsDecisions()
{
var aliasStore = new FakeAliasStore();
aliasStore.Register("GHSA-aaaa-bbbb-cccc",
(AliasSchemes.Ghsa, "GHSA-aaaa-bbbb-cccc"),
(AliasSchemes.Cve, "CVE-2025-4242"));
aliasStore.Register("CVE-2025-4242",
(AliasSchemes.Cve, "CVE-2025-4242"));
aliasStore.Register("OSV-2025-xyz",
(AliasSchemes.OsV, "OSV-2025-xyz"),
(AliasSchemes.Cve, "CVE-2025-4242"));
var advisoryStore = new FakeAdvisoryStore();
advisoryStore.Seed(CreateGhsaAdvisory(), CreateNvdAdvisory(), CreateOsvAdvisory());
var mergeEventStore = new InMemoryMergeEventStore();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 4, 1, 0, 0, 0, TimeSpan.Zero));
var writer = new MergeEventWriter(mergeEventStore, new CanonicalHashCalculator(), timeProvider, NullLogger<MergeEventWriter>.Instance);
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var aliasResolver = new AliasGraphResolver(aliasStore);
var canonicalMerger = new CanonicalMerger(timeProvider);
var eventLog = new RecordingAdvisoryEventLog();
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
var result = await service.MergeAsync("GHSA-aaaa-bbbb-cccc", CancellationToken.None);
Assert.NotNull(result.Merged);
Assert.Equal("OSV summary overrides", result.Merged!.Summary);
Assert.Empty(result.Conflicts);
var upserted = advisoryStore.LastUpserted;
Assert.NotNull(upserted);
Assert.Equal("CVE-2025-4242", upserted!.AdvisoryKey);
Assert.Equal("OSV summary overrides", upserted.Summary);
var mergeRecord = mergeEventStore.LastRecord;
Assert.NotNull(mergeRecord);
var summaryDecision = Assert.Single(mergeRecord!.FieldDecisions, decision => decision.Field == "summary");
Assert.Equal("osv", summaryDecision.SelectedSource);
Assert.Equal("freshness_override", summaryDecision.DecisionReason);
var appendRequest = eventLog.LastRequest;
Assert.NotNull(appendRequest);
Assert.Contains(appendRequest!.Statements, statement => string.Equals(statement.Advisory.AdvisoryKey, "CVE-2025-4242", StringComparison.OrdinalIgnoreCase));
Assert.True(appendRequest.Conflicts is null || appendRequest.Conflicts.Count == 0);
}
private static Advisory CreateGhsaAdvisory()
{
var recorded = DateTimeOffset.Parse("2025-03-01T00:00:00Z");
var provenance = new AdvisoryProvenance("ghsa", "map", "GHSA-aaaa-bbbb-cccc", recorded, new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
"GHSA-aaaa-bbbb-cccc",
"Container escape",
"Initial GHSA summary.",
"en",
recorded,
recorded,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-4242", "GHSA-aaaa-bbbb-cccc" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private static Advisory CreateNvdAdvisory()
{
var recorded = DateTimeOffset.Parse("2025-03-02T00:00:00Z");
var provenance = new AdvisoryProvenance("nvd", "map", "CVE-2025-4242", recorded, new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
"CVE-2025-4242",
"CVE-2025-4242",
"Baseline NVD summary.",
"en",
recorded,
recorded,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-4242" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private static Advisory CreateOsvAdvisory()
{
var recorded = DateTimeOffset.Parse("2025-03-05T12:00:00Z");
var provenance = new AdvisoryProvenance("osv", "map", "OSV-2025-xyz", recorded, new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
"OSV-2025-xyz",
"Container escape",
"OSV summary overrides",
"en",
recorded,
recorded,
"critical",
exploitKnown: false,
aliases: new[] { "OSV-2025-xyz", "CVE-2025-4242" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private static Advisory CreateVendorAdvisory()
{
var recorded = DateTimeOffset.Parse("2025-03-10T00:00:00Z");
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
"VSA-2025-5000",
"Vendor overrides severity",
"Vendor states critical impact.",
"en",
recorded,
recorded,
"critical",
exploitKnown: false,
aliases: new[] { "VSA-2025-5000", "CVE-2025-5000" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
private static Advisory CreateConflictingNvdAdvisory()
{
var recorded = DateTimeOffset.Parse("2025-03-09T00:00:00Z");
var provenance = new AdvisoryProvenance("nvd", "map", "CVE-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
"CVE-2025-5000",
"CVE-2025-5000",
"Baseline NVD entry.",
"en",
recorded,
recorded,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-5000" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
[Fact]
public async Task MergeAsync_PersistsConflictSummariesWithHashes()
{
var aliasStore = new FakeAliasStore();
aliasStore.Register("CVE-2025-5000",
(AliasSchemes.Cve, "CVE-2025-5000"));
aliasStore.Register("VSA-2025-5000",
(AliasSchemes.Cve, "CVE-2025-5000"));
var vendor = CreateVendorAdvisory();
var nvd = CreateConflictingNvdAdvisory();
var advisoryStore = new FakeAdvisoryStore();
advisoryStore.Seed(vendor, nvd);
var mergeEventStore = new InMemoryMergeEventStore();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 4, 2, 0, 0, 0, TimeSpan.Zero));
var writer = new MergeEventWriter(mergeEventStore, new CanonicalHashCalculator(), timeProvider, NullLogger<MergeEventWriter>.Instance);
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var aliasResolver = new AliasGraphResolver(aliasStore);
var canonicalMerger = new CanonicalMerger(timeProvider);
var eventLog = new RecordingAdvisoryEventLog();
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
var result = await service.MergeAsync("CVE-2025-5000", CancellationToken.None);
var conflict = Assert.Single(result.Conflicts);
Assert.Equal("CVE-2025-5000", conflict.VulnerabilityKey);
Assert.Equal("severity", conflict.Explainer.Type);
Assert.Equal("mismatch", conflict.Explainer.Reason);
Assert.Contains("vendor", conflict.Explainer.PrimarySources, StringComparer.OrdinalIgnoreCase);
Assert.Contains("nvd", conflict.Explainer.SuppressedSources, StringComparer.OrdinalIgnoreCase);
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
Assert.True(conflict.StatementIds.Length >= 2);
Assert.Equal(timeProvider.GetUtcNow(), conflict.RecordedAt);
var appendRequest = eventLog.LastRequest;
Assert.NotNull(appendRequest);
var appendedConflict = Assert.Single(appendRequest!.Conflicts!);
Assert.Equal(conflict.ConflictId, appendedConflict.ConflictId);
Assert.Equal(conflict.StatementIds, appendedConflict.StatementIds.ToImmutableArray());
}
private sealed class RecordingAdvisoryEventLog : IAdvisoryEventLog
{
public AdvisoryEventAppendRequest? LastRequest { get; private set; }
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
{
LastRequest = request;
return ValueTask.CompletedTask;
}
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
}
private sealed class FakeAliasStore : IAliasStore
{
private readonly ConcurrentDictionary<string, List<AliasRecord>> _records = new(StringComparer.OrdinalIgnoreCase);
public void Register(string advisoryKey, params (string Scheme, string Value)[] aliases)
{
var list = new List<AliasRecord>();
foreach (var (scheme, value) in aliases)
{
list.Add(new AliasRecord(advisoryKey, scheme, value, DateTimeOffset.UtcNow));
}
_records[advisoryKey] = list;
}
public Task<AliasUpsertResult> ReplaceAsync(string advisoryKey, IEnumerable<AliasEntry> aliases, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
return Task.FromResult(new AliasUpsertResult(advisoryKey, Array.Empty<AliasCollision>()));
}
public Task<IReadOnlyList<AliasRecord>> GetByAliasAsync(string scheme, string value, CancellationToken cancellationToken)
{
var matches = _records.Values
.SelectMany(static records => records)
.Where(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(record.Value, value, StringComparison.OrdinalIgnoreCase))
.ToList();
return Task.FromResult<IReadOnlyList<AliasRecord>>(matches);
}
public Task<IReadOnlyList<AliasRecord>> GetByAdvisoryAsync(string advisoryKey, CancellationToken cancellationToken)
{
if (_records.TryGetValue(advisoryKey, out var records))
{
return Task.FromResult<IReadOnlyList<AliasRecord>>(records);
}
return Task.FromResult<IReadOnlyList<AliasRecord>>(Array.Empty<AliasRecord>());
}
}
private sealed class FakeAdvisoryStore : IAdvisoryStore
{
private readonly ConcurrentDictionary<string, Advisory> _advisories = new(StringComparer.OrdinalIgnoreCase);
public Advisory? LastUpserted { get; private set; }
public void Seed(params Advisory[] advisories)
{
foreach (var advisory in advisories)
{
_advisories[advisory.AdvisoryKey] = advisory;
}
}
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_ = session;
_advisories.TryGetValue(advisoryKey, out var advisory);
return Task.FromResult(advisory);
}
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_ = session;
return Task.FromResult<IReadOnlyList<Advisory>>(Array.Empty<Advisory>());
}
public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_ = session;
_advisories[advisory.AdvisoryKey] = advisory;
LastUpserted = advisory;
return Task.CompletedTask;
}
public IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_ = session;
return AsyncEnumerable.Empty<Advisory>();
}
}
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)
{
return Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
}
}
}

View File

@@ -0,0 +1,628 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Merge.Options;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.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);
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
var expectedMergeTimestamp = timeProvider.GetUtcNow();
var merged = merger.Merge(new[] { nvd, redHat }).Advisory;
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);
var rangeMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
Assert.Equal(1, rangeMeasurement.Value);
Assert.Contains(rangeMeasurement.Tags, tag => string.Equals(tag.Key, "suppressed_source", StringComparison.Ordinal) && tag.Value?.ToString()?.Contains("nvd", StringComparison.OrdinalIgnoreCase) == true);
var severityConflict = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
Assert.Equal(1, severityConflict.Value);
Assert.Contains(severityConflict.Tags, tag => string.Equals(tag.Key, "type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "severity", 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" },
credits: Array.Empty<AdvisoryCredit>(),
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" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { kevProvenance });
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
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_UnionsCreditsFromSources()
{
var timeProvider = new FakeTimeProvider();
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var ghsaCredits = new[]
{
new AdvisoryCredit(
displayName: "maintainer-team",
role: "remediation_developer",
contacts: new[] { "https://github.com/maintainer-team" },
provenance: new AdvisoryProvenance(
"ghsa",
"credit",
"mantainer-team",
timeProvider.GetUtcNow(),
new[] { ProvenanceFieldMasks.Credits })),
new AdvisoryCredit(
displayName: "security-reporter",
role: "reporter",
contacts: new[] { "https://github.com/security-reporter" },
provenance: new AdvisoryProvenance(
"ghsa",
"credit",
"security-reporter",
timeProvider.GetUtcNow(),
new[] { ProvenanceFieldMasks.Credits })),
};
var ghsa = new Advisory(
"CVE-2025-9000",
"GHSA advisory",
"Reported in GHSA",
"en",
timeProvider.GetUtcNow(),
timeProvider.GetUtcNow(),
"high",
exploitKnown: false,
aliases: new[] { "GHSA-aaaa-bbbb-cccc", "CVE-2025-9000" },
credits: ghsaCredits,
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-aaaa-bbbb-cccc", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
var osvCredits = new[]
{
new AdvisoryCredit(
displayName: "osv-researcher",
role: "reporter",
contacts: new[] { "mailto:osv-researcher@example.com" },
provenance: new AdvisoryProvenance(
"osv",
"credit",
"osv-researcher",
timeProvider.GetUtcNow(),
new[] { ProvenanceFieldMasks.Credits })),
new AdvisoryCredit(
displayName: "maintainer-team",
role: "remediation_developer",
contacts: new[] { "https://github.com/maintainer-team" },
provenance: new AdvisoryProvenance(
"osv",
"credit",
"maintainer-team",
timeProvider.GetUtcNow(),
new[] { ProvenanceFieldMasks.Credits })),
};
var osv = new Advisory(
"CVE-2025-9000",
"OSV advisory",
"Reported in OSV.dev",
"en",
timeProvider.GetUtcNow().AddDays(-1),
timeProvider.GetUtcNow().AddHours(-1),
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-9000" },
credits: osvCredits,
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/CVE-2025-9000", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
Assert.Equal("CVE-2025-9000", merged.AdvisoryKey);
Assert.Contains(merged.Credits, credit =>
string.Equals(credit.DisplayName, "maintainer-team", StringComparison.OrdinalIgnoreCase) &&
string.Equals(credit.Role, "remediation_developer", StringComparison.OrdinalIgnoreCase));
Assert.Contains(merged.Credits, credit =>
string.Equals(credit.DisplayName, "osv-researcher", StringComparison.OrdinalIgnoreCase) &&
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
Assert.Contains(merged.Credits, credit =>
string.Equals(credit.DisplayName, "security-reporter", StringComparison.OrdinalIgnoreCase) &&
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "ghsa");
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "osv");
}
[Fact]
public void Merge_AcscActsAsEnrichmentSource()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var vendorDocumentProvenance = new AdvisoryProvenance(
source: "vndr-cisco",
kind: "document",
value: "https://vendor.example/advisories/router-critical",
recordedAt: timeProvider.GetUtcNow(),
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
var vendorReference = new AdvisoryReference(
"https://vendor.example/advisories/router-critical",
kind: "advisory",
sourceTag: "vendor",
summary: "Vendor advisory",
provenance: new AdvisoryProvenance("vndr-cisco", "reference", "https://vendor.example/advisories/router-critical", timeProvider.GetUtcNow()));
var vendorPackage = new AffectedPackage(
AffectedPackageTypes.Vendor,
"ExampleCo Router X",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
provenance: new[] { vendorDocumentProvenance });
var vendor = new Advisory(
advisoryKey: "acsc-2025-010",
title: "Vendor Critical Router Advisory",
summary: "Vendor-confirmed exploit.",
language: "en",
published: new DateTimeOffset(2025, 10, 11, 23, 0, 0, TimeSpan.Zero),
modified: new DateTimeOffset(2025, 10, 11, 23, 30, 0, TimeSpan.Zero),
severity: "critical",
exploitKnown: false,
aliases: new[] { "VENDOR-2025-010" },
references: new[] { vendorReference },
affectedPackages: new[] { vendorPackage },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { vendorDocumentProvenance });
var acscDocumentProvenance = new AdvisoryProvenance(
source: "acsc",
kind: "document",
value: "https://origin.example/feeds/alerts/rss",
recordedAt: timeProvider.GetUtcNow(),
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
var acscReference = new AdvisoryReference(
"https://origin.example/advisories/router-critical",
kind: "advisory",
sourceTag: "acsc",
summary: "ACSC alert",
provenance: new AdvisoryProvenance("acsc", "reference", "https://origin.example/advisories/router-critical", timeProvider.GetUtcNow()));
var acscPackage = new AffectedPackage(
AffectedPackageTypes.Vendor,
"ExampleCo Router X",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
provenance: new[] { acscDocumentProvenance });
var acsc = new Advisory(
advisoryKey: "acsc-2025-010",
title: "ACSC Router Alert",
summary: "ACSC recommends installing vendor update.",
language: "en",
published: new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
modified: null,
severity: "medium",
exploitKnown: false,
aliases: new[] { "ACSC-2025-010" },
references: new[] { acscReference },
affectedPackages: new[] { acscPackage },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { acscDocumentProvenance });
var merged = merger.Merge(new[] { acsc, vendor }).Advisory;
Assert.Equal("critical", merged.Severity); // ACSC must not override vendor severity
Assert.Equal("Vendor-confirmed exploit.", merged.Summary);
Assert.Contains("ACSC-2025-010", merged.Aliases);
Assert.Contains("VENDOR-2025-010", merged.Aliases);
Assert.Contains(merged.References, reference => reference.SourceTag == "vendor" && reference.Url == vendorReference.Url);
Assert.Contains(merged.References, reference => reference.SourceTag == "acsc" && reference.Url == acscReference.Url);
var enrichedPackage = Assert.Single(merged.AffectedPackages, package => package.Identifier == "ExampleCo Router X");
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "vndr-cisco");
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "acsc");
Assert.Contains(merged.Provenance, provenance => provenance.Source == "acsc");
Assert.Contains(merged.Provenance, provenance => provenance.Source == "vndr-cisco");
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge" && (provenance.Value?.Contains("acsc", StringComparison.OrdinalIgnoreCase) ?? false));
}
[Fact]
public void Merge_RecordsNormalizedRuleMetrics()
{
var now = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
var normalizedRule = new NormalizedVersionRule(
NormalizedVersionSchemes.SemVer,
NormalizedVersionRuleTypes.Range,
min: "1.0.0",
minInclusive: true,
max: "2.0.0",
maxInclusive: false,
notes: "ghsa:GHSA-xxxx-yyyy");
var ghsaProvenance = new AdvisoryProvenance("ghsa", "package", "pkg:npm/example", now);
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
NormalizedVersionSchemes.SemVer,
"1.0.0",
"2.0.0",
null,
">= 1.0.0 < 2.0.0",
ghsaProvenance)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[]
{
ghsaProvenance,
},
normalizedVersions: new[] { normalizedRule });
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
NormalizedVersionSchemes.SemVer,
"1.0.0",
"2.0.0",
null,
">= 1.0.0 < 2.0.0",
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/example", now))
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[]
{
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
},
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var nvdExclusivePackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/another",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
NormalizedVersionSchemes.SemVer,
"3.0.0",
null,
null,
">= 3.0.0",
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/another", now))
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[]
{
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
},
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsaAdvisory = new Advisory(
"CVE-2025-7000",
"GHSA advisory",
"GHSA summary",
"en",
now,
now,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-7000", "GHSA-xxxx-yyyy" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[] { ghsaPackage },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[]
{
new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-xxxx-yyyy", now),
});
var nvdAdvisory = new Advisory(
"CVE-2025-7000",
"NVD entry",
"NVD summary",
"en",
now,
now,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-7000" },
credits: Array.Empty<AdvisoryCredit>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[] { nvdPackage, nvdExclusivePackage },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[]
{
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
});
var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory }).Advisory;
Assert.Equal(2, merged.AffectedPackages.Length);
var normalizedPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/example");
Assert.Single(normalizedPackage.NormalizedVersions);
var missingPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/another");
Assert.Empty(missingPackage.NormalizedVersions);
Assert.NotEmpty(missingPackage.VersionRanges);
var normalizedMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules").ToList();
Assert.Contains(normalizedMeasurements, measurement =>
measurement.Value == 1
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "scheme", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal))
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal)));
var missingMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules_missing").ToList();
var missingMeasurement = Assert.Single(missingMeasurements);
Assert.Equal(1, missingMeasurement.Value);
Assert.Contains(missingMeasurement.Tags, tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal));
}
[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.Concelier.Merge");
var merger = new AdvisoryPrecedenceMerger(
new AffectedPackagePrecedenceResolver(),
options,
timeProvider,
logger);
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
var merged = merger.Merge(new[] { redHat, nvd }).Advisory;
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 == "concelier.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);
Assert.DoesNotContain(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
var conflictMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
Assert.Equal(1, conflictMeasurement.Value);
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "type" && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "reason" && string.Equals(tag.Value?.ToString(), "mismatch", StringComparison.OrdinalIgnoreCase));
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" },
credits: Array.Empty<AdvisoryCredit>(),
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" },
credits: Array.Empty<AdvisoryCredit>(),
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);
}
}

View File

@@ -0,0 +1,96 @@
using System;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.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 result = resolver.Merge(new[] { nvd, redHat });
var package = Assert.Single(result.Packages);
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");
var rangeOverride = Assert.Single(result.Overrides);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", rangeOverride.Identifier);
Assert.Equal(0, rangeOverride.PrimaryRank);
Assert.True(rangeOverride.SuppressedRank >= rangeOverride.PrimaryRank);
Assert.Equal(0, rangeOverride.PrimaryRangeCount);
Assert.Equal(1, rangeOverride.SuppressedRangeCount);
}
[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 result = resolver.Merge(new[] { nvd });
var package = Assert.Single(result.Packages);
Assert.Equal(nvd.Identifier, package.Identifier);
Assert.Equal(nvd.VersionRanges.Single().RangeExpression, package.VersionRanges.Single().RangeExpression);
Assert.Equal("nvd", package.Provenance.Single().Source);
Assert.Empty(result.Overrides);
}
}

View File

@@ -0,0 +1,135 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Merge.Tests;
[Collection("mongo-fixture")]
public sealed class AliasGraphResolverTests : IClassFixture<MongoIntegrationFixture>
{
private readonly MongoIntegrationFixture _fixture;
public AliasGraphResolverTests(MongoIntegrationFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task ResolveAsync_ReturnsCollisions_WhenAliasesOverlap()
{
await DropAliasCollectionAsync();
var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-1",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-1") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-2",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-2") },
timestamp.AddMinutes(1),
CancellationToken.None);
var result = await resolver.ResolveAsync("ADV-1", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("ADV-1", result.AdvisoryKey);
Assert.NotEmpty(result.Collisions);
var collision = Assert.Single(result.Collisions);
Assert.Equal("CVE", collision.Scheme);
Assert.Contains("ADV-1", collision.AdvisoryKeys);
Assert.Contains("ADV-2", collision.AdvisoryKeys);
}
[Fact]
public async Task BuildComponentAsync_TracesConnectedAdvisories()
{
await DropAliasCollectionAsync();
var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-A",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-A") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-B",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-B"), new AliasEntry("OSV", "OSV-2025-1") },
timestamp.AddMinutes(1),
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-C",
new[] { new AliasEntry("OSV", "OSV-2025-1"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-C") },
timestamp.AddMinutes(2),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-A", CancellationToken.None);
Assert.Contains("ADV-A", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-B", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-C", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.NotEmpty(component.Collisions);
Assert.True(component.AliasMap.ContainsKey("ADV-A"));
Assert.Contains(component.AliasMap["ADV-B"], record => record.Scheme == "OSV" && record.Value == "OSV-2025-1");
}
private async Task DropAliasCollectionAsync()
{
try
{
await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.Alias);
}
catch (MongoDB.Driver.MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
{
}
}
[Fact]
public async Task BuildComponentAsync_LinksOsvAndGhsaAliases()
{
await DropAliasCollectionAsync();
var aliasStore = new AliasStore(_fixture.Database, NullLogger<AliasStore>.Instance);
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-OSV",
new[]
{
new AliasEntry("OSV", "OSV-2025-2001"),
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-OSV"),
},
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-GHSA",
new[]
{
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-GHSA"),
},
timestamp.AddMinutes(1),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-OSV", CancellationToken.None);
Assert.Contains("ADV-GHSA", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains(component.Collisions, collision => collision.Scheme == "GHSA" && collision.Value == "GHSA-zzzz-zzzz-zzzz");
}
}

View File

@@ -0,0 +1,86 @@
using System.Linq;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.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));
}
}

View File

@@ -0,0 +1,84 @@
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.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);
}
}

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
namespace StellaOps.Concelier.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, Array.Empty<MergeFieldDecision>(), 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>(), Array.Empty<MergeFieldDecision>(), 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>());
}
}

View File

@@ -0,0 +1,234 @@
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.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.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 }).Advisory;
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, Array.Empty<MergeFieldDecision>(), 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);
}
[Fact]
public async Task MergePipeline_IsDeterministicAcrossRuns()
{
await EnsureInitializedAsync();
var merger = _merger!;
var calculator = new CanonicalHashCalculator();
var firstResult = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
var secondResult = merger.Merge(new[] { CreateNvdBaseline(), CreateVendorOverride() });
var first = firstResult.Advisory;
var second = secondResult.Advisory;
var firstHash = calculator.ComputeHash(first);
var secondHash = calculator.ComputeHash(second);
Assert.Equal(firstHash, secondHash);
Assert.Equal(first.AdvisoryKey, second.AdvisoryKey);
Assert.Equal(first.Aliases.Length, second.Aliases.Length);
Assert.True(first.Aliases.SequenceEqual(second.Aliases));
}
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 });
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
namespace StellaOps.Concelier.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);
}

View File

@@ -0,0 +1,108 @@
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.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);
}
}

View File

@@ -0,0 +1,67 @@
using StellaOps.Concelier.Merge.Comparers;
namespace StellaOps.Concelier.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);
}
}

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.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()
{
}
}
}