Files
git.stella-ops.org/src/Concelier/__Tests/StellaOps.Concelier.Merge.Tests/AdvisoryMergeServiceTests.cs

333 lines
14 KiB
C#

#pragma warning disable CONCELIER0001 // AdvisoryMergeService is deprecated - tests verify existing behavior during Link-Not-Merge transition
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core;
using Xunit;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Aliases;
using StellaOps.Concelier.Storage.MergeEvents;
using StellaOps.Provenance;
using StellaOps.TestKit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryMergeServiceTests
{
[Trait("Category", TestCategories.Unit)]
[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 });
}
[Trait("Category", TestCategories.Unit)]
[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.ToArray(), appendedConflict.StatementIds.ToArray());
}
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 AttachStatementProvenanceAsync(Guid statementId, DsseProvenance provenance, TrustInfo trust, CancellationToken cancellationToken)
=> 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)
{
_advisories.TryGetValue(advisoryKey, out var advisory);
return Task.FromResult(advisory);
}
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<Advisory>>(Array.Empty<Advisory>());
}
public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken)
{
_advisories[advisory.AdvisoryKey] = advisory;
LastUpserted = advisory;
return Task.CompletedTask;
}
public IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken)
{
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>());
}
}
}