// ----------------------------------------------------------------------------- // 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 }