up
This commit is contained in:
@@ -2,109 +2,109 @@ 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);
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
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;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
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");
|
||||
@@ -207,120 +207,119 @@ public sealed class AdvisoryMergeServiceTests
|
||||
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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user