#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.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.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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), 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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), 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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), 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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), 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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), 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.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.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 ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken) { throw new NotSupportedException(); } } private sealed class FakeAliasStore : IAliasStore { private readonly ConcurrentDictionary> _records = new(StringComparer.OrdinalIgnoreCase); public void Register(string advisoryKey, params (string Scheme, string Value)[] aliases) { var list = new List(); foreach (var (scheme, value) in aliases) { list.Add(new AliasRecord(advisoryKey, scheme, value, DateTimeOffset.UtcNow)); } _records[advisoryKey] = list; } public Task ReplaceAsync(string advisoryKey, IEnumerable aliases, DateTimeOffset updatedAt, CancellationToken cancellationToken) { return Task.FromResult(new AliasUpsertResult(advisoryKey, Array.Empty())); } public Task> 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>(matches); } public Task> GetByAdvisoryAsync(string advisoryKey, CancellationToken cancellationToken) { if (_records.TryGetValue(advisoryKey, out var records)) { return Task.FromResult>(records); } return Task.FromResult>(Array.Empty()); } } private sealed class FakeAdvisoryStore : IAdvisoryStore { private readonly ConcurrentDictionary _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 FindAsync(string advisoryKey, CancellationToken cancellationToken) { _advisories.TryGetValue(advisoryKey, out var advisory); return Task.FromResult(advisory); } public Task> GetRecentAsync(int limit, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken) { _advisories[advisory.AdvisoryKey] = advisory; LastUpserted = advisory; return Task.CompletedTask; } public IAsyncEnumerable StreamAsync(CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } } 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> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } } }