using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using MongoDB.Driver; using StellaOps.Feedser.Merge.Services; using StellaOps.Feedser.Models; using StellaOps.Feedser.Storage.Mongo; using StellaOps.Feedser.Storage.Mongo.MergeEvents; using StellaOps.Feedser.Testing; namespace StellaOps.Feedser.Merge.Tests; [Collection("mongo-fixture")] public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime { private readonly MongoIntegrationFixture _fixture; private MergeEventStore? _mergeEventStore; private MergeEventWriter? _mergeEventWriter; private AdvisoryPrecedenceMerger? _merger; private FakeTimeProvider? _timeProvider; public MergePrecedenceIntegrationTests(MongoIntegrationFixture fixture) { _fixture = fixture; } [Fact] public async Task MergePipeline_PsirtOverridesNvd_AndKevOnlyTogglesExploitKnown() { await EnsureInitializedAsync(); var merger = _merger!; var writer = _mergeEventWriter!; var store = _mergeEventStore!; var timeProvider = _timeProvider!; var expectedTimestamp = timeProvider.GetUtcNow(); var nvd = CreateNvdBaseline(); var vendor = CreateVendorOverride(); var kev = CreateKevSignal(); var merged = merger.Merge(new[] { nvd, vendor, kev }); Assert.Equal("CVE-2025-1000", merged.AdvisoryKey); Assert.Equal("Vendor Security Advisory", merged.Title); Assert.Equal("Critical impact on supported platforms.", merged.Summary); Assert.Equal("critical", merged.Severity); Assert.True(merged.ExploitKnown); var affected = Assert.Single(merged.AffectedPackages); Assert.Empty(affected.VersionRanges); Assert.Contains(affected.Statuses, status => status.Status == "known_affected" && status.Provenance.Source == "vendor"); var mergeProvenance = Assert.Single(merged.Provenance, p => p.Source == "merge"); Assert.Equal("precedence", mergeProvenance.Kind); Assert.Equal(expectedTimestamp, mergeProvenance.RecordedAt); Assert.Contains("vendor", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase); Assert.Contains("kev", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase); var inputDocumentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; var record = await writer.AppendAsync(merged.AdvisoryKey, nvd, merged, inputDocumentIds, CancellationToken.None); Assert.Equal(expectedTimestamp, record.MergedAt); Assert.Equal(inputDocumentIds, record.InputDocumentIds); Assert.NotEqual(record.BeforeHash, record.AfterHash); var records = await store.GetRecentAsync(merged.AdvisoryKey, 5, CancellationToken.None); var persisted = Assert.Single(records); Assert.Equal(record.Id, persisted.Id); Assert.Equal(merged.AdvisoryKey, persisted.AdvisoryKey); Assert.True(persisted.AfterHash.Length > 0); Assert.True(persisted.BeforeHash.Length > 0); } public async Task InitializeAsync() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero)) { AutoAdvanceAmount = TimeSpan.Zero, }; _merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), _timeProvider); _mergeEventStore = new MergeEventStore(_fixture.Database, NullLogger.Instance); _mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger.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(), 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(), 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(), affectedPackages: Array.Empty(), cvssMetrics: Array.Empty(), provenance: new[] { provenance }); } }