// -----------------------------------------------------------------------------
// MergeExportSnapshotTests.cs
// Sprint: SPRINT_5100_0009_0002
// Task: CONCELIER-5100-011
// Description: Snapshot tests for merged normalized DB export (canonical JSON)
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Canonical.Json;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
///
/// Snapshot tests for merged advisory exports.
/// Verifies that merged advisories produce deterministic canonical JSON output.
///
public sealed class MergeExportSnapshotTests
{
private static readonly DateTimeOffset FixedTime = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
#region Canonical JSON Snapshot Tests
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ProducesCanonicalJsonSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - verify canonical JSON structure (not exact match due to merge provenance timestamp)
canonicalJson.Should().Contain("\"advisoryKey\":\"CVE-2025-1000\"");
canonicalJson.Should().Contain("\"severity\":\"high\""); // Vendor takes precedence
canonicalJson.Should().Contain("\"exploitKnown\":false");
canonicalJson.Should().Contain("\"RHSA-2025:1000\""); // Vendor alias preserved
canonicalJson.Should().Contain("\"CVE-2025-1000\""); // CVE alias preserved
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_CanonicalJsonIsDeterministic()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act - merge and serialize multiple times
var results = new List();
for (int i = 0; i < 3; i++)
{
timeProvider.SetUtcNow(FixedTime); // Reset for determinism
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
results.Add(CanonJson.Serialize(merged));
}
// Assert
results.Distinct().Should().HaveCount(1,
"canonical JSON should be identical across multiple merge runs");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_OrderedFieldsInCanonicalJson()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
// Act
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - canonical JSON should have fields in deterministic order
var advisoryKeyIndex = canonicalJson.IndexOf("\"advisoryKey\"", StringComparison.Ordinal);
var titleIndex = canonicalJson.IndexOf("\"title\"", StringComparison.Ordinal);
var severityIndex = canonicalJson.IndexOf("\"severity\"", StringComparison.Ordinal);
advisoryKeyIndex.Should().BeGreaterOrEqualTo(0);
titleIndex.Should().BeGreaterOrEqualTo(0);
severityIndex.Should().BeGreaterOrEqualTo(0);
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_AliasesOrderedDeterministically()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var merged = merger.Merge(new[] { a, b, c }).Advisory;
// Assert - aliases should be collected from all sources
merged.Aliases.Should().Contain("CVE-2025-3000");
merged.Aliases.Should().Contain("RHSA-2025:3000");
merged.Aliases.Should().Contain("GHSA-3333-4444-5555");
merged.Aliases.Should().Contain("OSV-2025-3000");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ProvenanceOrderedBySource()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (a, b, c) = CreateThreeAdvisories();
// Act
var merged = merger.Merge(new[] { a, b, c }).Advisory;
var canonicalJson = CanonJson.Serialize(merged);
// Assert - provenance should include all sources
merged.Provenance.Should().HaveCountGreaterThan(3); // Original + merge provenance
merged.Provenance.Should().Contain(p => p.Source == "redhat");
merged.Provenance.Should().Contain(p => p.Source == "ghsa");
merged.Provenance.Should().Contain(p => p.Source == "osv");
merged.Provenance.Should().Contain(p => p.Source == "merge");
}
#endregion
#region Snapshot Serialization Tests
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_ProducesDeterministicOutput()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var results = new List();
for (int i = 0; i < 3; i++)
{
results.Add(SnapshotSerializer.ToSnapshot(merged));
}
// Assert
results.Distinct().Should().HaveCount(1,
"SnapshotSerializer should produce identical output");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_ContainsExpectedFields()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateAdvisoriesWithCvss();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
snapshot.Should().Contain("CVE-2025-1000");
snapshot.Should().Contain("CVSS:3.1"); // CVSS vector preserved
snapshot.Should().Contain("redhat"); // Source provenance
snapshot.Should().Contain("nvd"); // Source provenance
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void SnapshotSerializer_MergedAdvisory_PreservesAffectedPackages()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (vendor, nvd) = CreateVendorAndNvdAdvisories();
var merged = merger.Merge(new[] { vendor, nvd }).Advisory;
// Act
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
snapshot.Should().Contain("affectedPackages");
snapshot.Should().Contain("cpe:2.3:o:redhat:enterprise_linux:9");
}
#endregion
#region Export Result Verification
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ExploitKnownFromKev_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var baseAdvisory = CreateNvdAdvisory();
var kevAdvisory = CreateKevAdvisory();
// Act
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.ExploitKnown.Should().BeTrue("KEV should set exploitKnown to true");
snapshot.Should().Contain("\"exploitKnown\":true");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_CreditsFromMultipleSources_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (ghsa, osv) = CreateAdvisoriesWithCredits();
// Act
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.Credits.Should().HaveCountGreaterThan(2, "credits from multiple sources should be merged");
snapshot.Should().Contain("credits");
snapshot.Should().Contain("researcher-a");
snapshot.Should().Contain("researcher-b");
}
[Fact]
[Trait("Lane", "Unit")]
[Trait("Category", "Snapshot")]
public void MergedAdvisory_ReferencesFromMultipleSources_PreservedInSnapshot()
{
// Arrange
var timeProvider = new FakeTimeProvider(FixedTime);
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (ghsa, osv) = CreateAdvisoriesWithReferences();
// Act
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
var snapshot = SnapshotSerializer.ToSnapshot(merged);
// Assert
merged.References.Should().HaveCountGreaterThan(2, "references from multiple sources should be merged");
snapshot.Should().Contain("references");
snapshot.Should().Contain("github.com");
snapshot.Should().Contain("osv.dev");
}
#endregion
#region Helper Methods
private static (Advisory Vendor, Advisory Nvd) CreateVendorAndNvdAdvisories()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000",
"Red Hat Security Advisory",
"Vendor-confirmed impact",
"en",
FixedTime,
FixedTime,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", "RHSA-2025:1000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
Array.Empty(),
new[] { new AffectedPackageStatus("known_affected", vendorProvenance) },
new[] { vendorProvenance })
},
cvssMetrics: Array.Empty(),
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000",
"CVE-2025-1000",
"NVD summary",
"en",
FixedTime.AddDays(-1),
FixedTime,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
null,
new[] { new AffectedVersionRange("cpe", null, null, null, "<=9.0", nvdProvenance) },
Array.Empty(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty(),
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
private static (Advisory A, Advisory B, Advisory C) CreateThreeAdvisories()
{
var redhatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:3000", FixedTime);
var redhat = new Advisory(
"CVE-2025-3000", "Red Hat Advisory", "Vendor summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "RHSA-2025:3000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { redhatProvenance });
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-3333-4444-5555", FixedTime);
var ghsa = new Advisory(
"CVE-2025-3000", "GHSA Advisory", "GHSA summary", "en",
FixedTime.AddHours(1), FixedTime.AddHours(1), "high", exploitKnown: true,
aliases: new[] { "CVE-2025-3000", "GHSA-3333-4444-5555" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/OSV-2025-3000", FixedTime);
var osv = new Advisory(
"CVE-2025-3000", "OSV Advisory", "OSV summary", "en",
FixedTime.AddHours(2), FixedTime.AddHours(2), "medium", exploitKnown: false,
aliases: new[] { "CVE-2025-3000", "OSV-2025-3000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { osvProvenance });
return (redhat, ghsa, osv);
}
private static (Advisory Vendor, Advisory Nvd) CreateAdvisoriesWithCvss()
{
var vendorProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:1000", FixedTime);
var vendor = new Advisory(
"CVE-2025-1000", "Red Hat Advisory", "Summary", "en",
FixedTime, FixedTime, "critical", exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
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:1000", FixedTime))
},
provenance: new[] { vendorProvenance });
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
var nvd = new Advisory(
"CVE-2025-1000", "CVE-2025-1000", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
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", 7.3, "high",
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", FixedTime))
},
provenance: new[] { nvdProvenance });
return (vendor, nvd);
}
private static Advisory CreateNvdAdvisory()
{
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov", FixedTime);
return new Advisory(
"CVE-2025-2000", "CVE-2025-2000", "NVD summary", "en",
FixedTime, FixedTime, "medium", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { nvdProvenance });
}
private static Advisory CreateKevAdvisory()
{
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", FixedTime);
return new Advisory(
"CVE-2025-2000", "Known Exploited Vulnerability", null, null,
null, null, null, exploitKnown: true,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty(),
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { kevProvenance });
}
private static (Advisory Ghsa, Advisory Osv) CreateAdvisoriesWithCredits()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000", "GHSA Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-a", "reporter", new[] { "https://example.com/a" },
new AdvisoryProvenance("ghsa", "credit", "researcher-a", FixedTime)),
new AdvisoryCredit("maintainer", "remediation_developer", new[] { "https://example.com/m" },
new AdvisoryProvenance("ghsa", "credit", "maintainer", FixedTime))
},
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000", "OSV Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: new[]
{
new AdvisoryCredit("researcher-b", "reporter", new[] { "https://example.com/b" },
new AdvisoryProvenance("osv", "credit", "researcher-b", FixedTime))
},
references: Array.Empty(),
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
private static (Advisory Ghsa, Advisory Osv) CreateAdvisoriesWithReferences()
{
var ghsaProvenance = new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories", FixedTime);
var ghsa = new Advisory(
"CVE-2025-2000", "GHSA Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty(),
references: new[]
{
new AdvisoryReference("https://github.com/org/repo/security/advisories/GHSA-xxxx", "advisory", "ghsa", "GitHub advisory", ghsaProvenance),
new AdvisoryReference("https://github.com/org/repo/pull/123", "fix", "ghsa", "Fix PR", ghsaProvenance)
},
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { ghsaProvenance });
var osvProvenance = new AdvisoryProvenance("osv", "document", "https://osv.dev", FixedTime);
var osv = new Advisory(
"CVE-2025-2000", "OSV Advisory", "Summary", "en",
FixedTime, FixedTime, "high", exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
credits: Array.Empty(),
references: new[]
{
new AdvisoryReference("https://osv.dev/vulnerability/CVE-2025-2000", "advisory", "osv", "OSV entry", osvProvenance),
new AdvisoryReference("https://example.com/blog/vuln-disclosure", "article", "osv", "Blog post", osvProvenance)
},
affectedPackages: Array.Empty(),
cvssMetrics: Array.Empty(),
provenance: new[] { osvProvenance });
return (ghsa, osv);
}
#endregion
}