up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,92 +1,92 @@
using System;
using System.Linq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryIdentityResolverTests
{
private readonly AdvisoryIdentityResolver _resolver = new();
[Fact]
public void Resolve_GroupsBySharedCveAlias()
{
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
var clusters = _resolver.Resolve(new[] { nvd, vendor });
var cluster = Assert.Single(clusters);
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
}
[Fact]
public void Resolve_PrefersPsirtAliasWhenNoCve()
{
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
var clusters = _resolver.Resolve(new[] { vendor, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
}
[Fact]
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
{
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
var clusters = _resolver.Resolve(new[] { ghsa, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
}
[Fact]
public void Resolve_GroupsByKeyWhenNoAliases()
{
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
var clusters = _resolver.Resolve(new[] { first, second });
var cluster = Assert.Single(clusters);
Assert.Equal("custom-1", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
}
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
{
var provenance = new[]
{
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
};
return new Advisory(
key,
$"{key} title",
$"{key} summary",
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
exploitKnown: false,
aliases,
Array.Empty<AdvisoryCredit>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
provenance);
}
}
using System;
using System.Linq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryIdentityResolverTests
{
private readonly AdvisoryIdentityResolver _resolver = new();
[Fact]
public void Resolve_GroupsBySharedCveAlias()
{
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
var clusters = _resolver.Resolve(new[] { nvd, vendor });
var cluster = Assert.Single(clusters);
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
}
[Fact]
public void Resolve_PrefersPsirtAliasWhenNoCve()
{
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
var clusters = _resolver.Resolve(new[] { vendor, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
}
[Fact]
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
{
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
var clusters = _resolver.Resolve(new[] { ghsa, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
}
[Fact]
public void Resolve_GroupsByKeyWhenNoAliases()
{
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
var clusters = _resolver.Resolve(new[] { first, second });
var cluster = Assert.Single(clusters);
Assert.Equal("custom-1", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
}
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
{
var provenance = new[]
{
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
};
return new Advisory(
key,
$"{key} title",
$"{key} summary",
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
exploitKnown: false,
aliases,
Array.Empty<AdvisoryCredit>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
provenance);
}
}

View File

@@ -11,7 +11,7 @@ using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Aliases;
using StellaOps.Concelier.Storage.MergeEvents;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
namespace StellaOps.Concelier.Merge.Tests;

View File

@@ -1,96 +1,96 @@
using System;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AffectedPackagePrecedenceResolverTests
{
[Fact]
public void Merge_PrefersRedHatOverNvdForSameCpe()
{
var redHat = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: new[]
{
new AffectedPackageStatus(
status: "known_affected",
provenance: new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z"))
});
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "cpe",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: "<=9.0",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var result = resolver.Merge(new[] { nvd, redHat });
var package = Assert.Single(result.Packages);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
Assert.Empty(package.VersionRanges); // NVD range overridden
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
var rangeOverride = Assert.Single(result.Overrides);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", rangeOverride.Identifier);
Assert.Equal(0, rangeOverride.PrimaryRank);
Assert.True(rangeOverride.SuppressedRank >= rangeOverride.PrimaryRank);
Assert.Equal(0, rangeOverride.PrimaryRangeCount);
Assert.Equal(1, rangeOverride.SuppressedRangeCount);
}
[Fact]
public void Merge_KeepsNvdWhenNoHigherPrecedence()
{
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.0.1",
lastAffectedVersion: null,
rangeExpression: "<1.0.1",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var result = resolver.Merge(new[] { nvd });
var package = Assert.Single(result.Packages);
Assert.Equal(nvd.Identifier, package.Identifier);
Assert.Equal(nvd.VersionRanges.Single().RangeExpression, package.VersionRanges.Single().RangeExpression);
Assert.Equal("nvd", package.Provenance.Single().Source);
Assert.Empty(result.Overrides);
}
}
using System;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AffectedPackagePrecedenceResolverTests
{
[Fact]
public void Merge_PrefersRedHatOverNvdForSameCpe()
{
var redHat = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: new[]
{
new AffectedPackageStatus(
status: "known_affected",
provenance: new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z"))
});
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "cpe",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: "<=9.0",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var result = resolver.Merge(new[] { nvd, redHat });
var package = Assert.Single(result.Packages);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
Assert.Empty(package.VersionRanges); // NVD range overridden
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
var rangeOverride = Assert.Single(result.Overrides);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", rangeOverride.Identifier);
Assert.Equal(0, rangeOverride.PrimaryRank);
Assert.True(rangeOverride.SuppressedRank >= rangeOverride.PrimaryRank);
Assert.Equal(0, rangeOverride.PrimaryRangeCount);
Assert.Equal(1, rangeOverride.SuppressedRangeCount);
}
[Fact]
public void Merge_KeepsNvdWhenNoHigherPrecedence()
{
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.0.1",
lastAffectedVersion: null,
rangeExpression: "<1.0.1",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var result = resolver.Merge(new[] { nvd });
var package = Assert.Single(result.Packages);
Assert.Equal(nvd.Identifier, package.Identifier);
Assert.Equal(nvd.VersionRanges.Single().RangeExpression, package.VersionRanges.Single().RangeExpression);
Assert.Equal("nvd", package.Provenance.Single().Source);
Assert.Empty(result.Overrides);
}
}

View File

@@ -15,59 +15,59 @@ public sealed class AliasGraphResolverTests
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-1",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-1") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-2",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-2") },
timestamp.AddMinutes(1),
CancellationToken.None);
var result = await resolver.ResolveAsync("ADV-1", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("ADV-1", result.AdvisoryKey);
Assert.NotEmpty(result.Collisions);
var collision = Assert.Single(result.Collisions);
Assert.Equal("CVE", collision.Scheme);
Assert.Contains("ADV-1", collision.AdvisoryKeys);
Assert.Contains("ADV-2", collision.AdvisoryKeys);
}
await aliasStore.ReplaceAsync(
"ADV-1",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-1") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-2",
new[] { new AliasEntry("CVE", "CVE-2025-2000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-2") },
timestamp.AddMinutes(1),
CancellationToken.None);
var result = await resolver.ResolveAsync("ADV-1", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("ADV-1", result.AdvisoryKey);
Assert.NotEmpty(result.Collisions);
var collision = Assert.Single(result.Collisions);
Assert.Equal("CVE", collision.Scheme);
Assert.Contains("ADV-1", collision.AdvisoryKeys);
Assert.Contains("ADV-2", collision.AdvisoryKeys);
}
[Fact]
public async Task BuildComponentAsync_TracesConnectedAdvisories()
{
var aliasStore = new AliasStore();
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-A",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-A") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-B",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-B"), new AliasEntry("OSV", "OSV-2025-1") },
timestamp.AddMinutes(1),
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-C",
new[] { new AliasEntry("OSV", "OSV-2025-1"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-C") },
timestamp.AddMinutes(2),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-A", CancellationToken.None);
Assert.Contains("ADV-A", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-B", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-C", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.NotEmpty(component.Collisions);
Assert.True(component.AliasMap.ContainsKey("ADV-A"));
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-A",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-A") },
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-B",
new[] { new AliasEntry("CVE", "CVE-2025-4000"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-B"), new AliasEntry("OSV", "OSV-2025-1") },
timestamp.AddMinutes(1),
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-C",
new[] { new AliasEntry("OSV", "OSV-2025-1"), new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-C") },
timestamp.AddMinutes(2),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-A", CancellationToken.None);
Assert.Contains("ADV-A", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-B", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ADV-C", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.NotEmpty(component.Collisions);
Assert.True(component.AliasMap.ContainsKey("ADV-A"));
Assert.Contains(component.AliasMap["ADV-B"], record => record.Scheme == "OSV" && record.Value == "OSV-2025-1");
}
@@ -77,31 +77,31 @@ public sealed class AliasGraphResolverTests
var aliasStore = new AliasStore();
var resolver = new AliasGraphResolver(aliasStore);
var timestamp = DateTimeOffset.UtcNow;
await aliasStore.ReplaceAsync(
"ADV-OSV",
new[]
{
new AliasEntry("OSV", "OSV-2025-2001"),
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-OSV"),
},
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-GHSA",
new[]
{
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-GHSA"),
},
timestamp.AddMinutes(1),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-OSV", CancellationToken.None);
Assert.Contains("ADV-GHSA", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains(component.Collisions, collision => collision.Scheme == "GHSA" && collision.Value == "GHSA-zzzz-zzzz-zzzz");
}
}
await aliasStore.ReplaceAsync(
"ADV-OSV",
new[]
{
new AliasEntry("OSV", "OSV-2025-2001"),
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-OSV"),
},
timestamp,
CancellationToken.None);
await aliasStore.ReplaceAsync(
"ADV-GHSA",
new[]
{
new AliasEntry("GHSA", "GHSA-zzzz-zzzz-zzzz"),
new AliasEntry(AliasStoreConstants.PrimaryScheme, "ADV-GHSA"),
},
timestamp.AddMinutes(1),
CancellationToken.None);
var component = await resolver.BuildComponentAsync("ADV-OSV", CancellationToken.None);
Assert.Contains("ADV-GHSA", component.AdvisoryKeys, StringComparer.OrdinalIgnoreCase);
Assert.Contains(component.Collisions, collision => collision.Scheme == "GHSA" && collision.Value == "GHSA-zzzz-zzzz-zzzz");
}
}

View File

@@ -1,86 +1,86 @@
using System.Linq;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class CanonicalHashCalculatorTests
{
private static readonly Advisory SampleAdvisory = new(
advisoryKey: "CVE-2024-0001",
title: "Sample advisory",
summary: "A sample summary",
language: "EN",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "high",
exploitKnown: true,
aliases: new[] { "GHSA-xyz", "CVE-2024-0001" },
references: new[]
{
new AdvisoryReference("https://example.com/advisory", "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty),
new AdvisoryReference("https://example.com/blog", "article", "blog", summary: null, provenance: AdvisoryProvenance.Empty),
},
affectedPackages: new[]
{
new AffectedPackage(
type: AffectedPackageTypes.SemVer,
identifier: "pkg:npm/sample@1.0.0",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.2.0", null, null, AdvisoryProvenance.Empty),
new AffectedVersionRange("semver", "1.2.0", null, null, null, AdvisoryProvenance.Empty),
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { AdvisoryProvenance.Empty })
},
cvssMetrics: new[]
{
new CvssMetric("3.1", "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", AdvisoryProvenance.Empty)
},
provenance: new[] { AdvisoryProvenance.Empty });
[Fact]
public void ComputeHash_ReturnsDeterministicValue()
{
var calculator = new CanonicalHashCalculator();
var first = calculator.ComputeHash(SampleAdvisory);
var second = calculator.ComputeHash(SampleAdvisory);
Assert.Equal(first, second);
}
[Fact]
public void ComputeHash_IgnoresOrderingDifferences()
{
var calculator = new CanonicalHashCalculator();
var reordered = new Advisory(
SampleAdvisory.AdvisoryKey,
SampleAdvisory.Title,
SampleAdvisory.Summary,
SampleAdvisory.Language,
SampleAdvisory.Published,
SampleAdvisory.Modified,
SampleAdvisory.Severity,
SampleAdvisory.ExploitKnown,
aliases: SampleAdvisory.Aliases.Reverse().ToArray(),
references: SampleAdvisory.References.Reverse().ToArray(),
affectedPackages: SampleAdvisory.AffectedPackages.Reverse().ToArray(),
cvssMetrics: SampleAdvisory.CvssMetrics.Reverse().ToArray(),
provenance: SampleAdvisory.Provenance.Reverse().ToArray());
var originalHash = calculator.ComputeHash(SampleAdvisory);
var reorderedHash = calculator.ComputeHash(reordered);
Assert.Equal(originalHash, reorderedHash);
}
[Fact]
public void ComputeHash_NullReturnsEmpty()
{
var calculator = new CanonicalHashCalculator();
Assert.Empty(calculator.ComputeHash(null));
}
}
using System.Linq;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class CanonicalHashCalculatorTests
{
private static readonly Advisory SampleAdvisory = new(
advisoryKey: "CVE-2024-0001",
title: "Sample advisory",
summary: "A sample summary",
language: "EN",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "high",
exploitKnown: true,
aliases: new[] { "GHSA-xyz", "CVE-2024-0001" },
references: new[]
{
new AdvisoryReference("https://example.com/advisory", "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty),
new AdvisoryReference("https://example.com/blog", "article", "blog", summary: null, provenance: AdvisoryProvenance.Empty),
},
affectedPackages: new[]
{
new AffectedPackage(
type: AffectedPackageTypes.SemVer,
identifier: "pkg:npm/sample@1.0.0",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.2.0", null, null, AdvisoryProvenance.Empty),
new AffectedVersionRange("semver", "1.2.0", null, null, null, AdvisoryProvenance.Empty),
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { AdvisoryProvenance.Empty })
},
cvssMetrics: new[]
{
new CvssMetric("3.1", "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", AdvisoryProvenance.Empty)
},
provenance: new[] { AdvisoryProvenance.Empty });
[Fact]
public void ComputeHash_ReturnsDeterministicValue()
{
var calculator = new CanonicalHashCalculator();
var first = calculator.ComputeHash(SampleAdvisory);
var second = calculator.ComputeHash(SampleAdvisory);
Assert.Equal(first, second);
}
[Fact]
public void ComputeHash_IgnoresOrderingDifferences()
{
var calculator = new CanonicalHashCalculator();
var reordered = new Advisory(
SampleAdvisory.AdvisoryKey,
SampleAdvisory.Title,
SampleAdvisory.Summary,
SampleAdvisory.Language,
SampleAdvisory.Published,
SampleAdvisory.Modified,
SampleAdvisory.Severity,
SampleAdvisory.ExploitKnown,
aliases: SampleAdvisory.Aliases.Reverse().ToArray(),
references: SampleAdvisory.References.Reverse().ToArray(),
affectedPackages: SampleAdvisory.AffectedPackages.Reverse().ToArray(),
cvssMetrics: SampleAdvisory.CvssMetrics.Reverse().ToArray(),
provenance: SampleAdvisory.Provenance.Reverse().ToArray());
var originalHash = calculator.ComputeHash(SampleAdvisory);
var reorderedHash = calculator.ComputeHash(reordered);
Assert.Equal(originalHash, reorderedHash);
}
[Fact]
public void ComputeHash_NullReturnsEmpty()
{
var calculator = new CanonicalHashCalculator();
Assert.Empty(calculator.ComputeHash(null));
}
}

View File

@@ -1,84 +1,84 @@
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class DebianEvrComparerTests
{
[Theory]
[InlineData("1:1.2.3-1", 1, "1.2.3", "1")]
[InlineData("1.2.3-1", 0, "1.2.3", "1")]
[InlineData("2:4.5", 2, "4.5", "")]
[InlineData("abc", 0, "abc", "")]
public void TryParse_ReturnsComponents(string input, int expectedEpoch, string expectedVersion, string expectedRevision)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.True(success);
Assert.NotNull(evr);
Assert.Equal(expectedEpoch, evr!.Epoch);
Assert.Equal(expectedVersion, evr.Version);
Assert.Equal(expectedRevision, evr.Revision);
Assert.Equal(input, evr.Original);
}
[Theory]
[InlineData("")]
[InlineData(":1.0-1")]
[InlineData("1:")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.False(success);
Assert.Null(evr);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var lower = "0:2.0-1";
var higher = "1:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesVersionOrdering()
{
var lower = "0:1.2.3-1";
var higher = "0:1.10.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "0:1.0~beta1-1";
var stable = "0:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_RevisionBreaksTies()
{
var first = "0:1.0-1";
var second = "0:1.0-2";
Assert.True(DebianEvrComparer.Instance.Compare(second, first) > 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-an-evr";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class DebianEvrComparerTests
{
[Theory]
[InlineData("1:1.2.3-1", 1, "1.2.3", "1")]
[InlineData("1.2.3-1", 0, "1.2.3", "1")]
[InlineData("2:4.5", 2, "4.5", "")]
[InlineData("abc", 0, "abc", "")]
public void TryParse_ReturnsComponents(string input, int expectedEpoch, string expectedVersion, string expectedRevision)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.True(success);
Assert.NotNull(evr);
Assert.Equal(expectedEpoch, evr!.Epoch);
Assert.Equal(expectedVersion, evr.Version);
Assert.Equal(expectedRevision, evr.Revision);
Assert.Equal(input, evr.Original);
}
[Theory]
[InlineData("")]
[InlineData(":1.0-1")]
[InlineData("1:")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.False(success);
Assert.Null(evr);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var lower = "0:2.0-1";
var higher = "1:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesVersionOrdering()
{
var lower = "0:1.2.3-1";
var higher = "0:1.10.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "0:1.0~beta1-1";
var stable = "0:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_RevisionBreaksTies()
{
var first = "0:1.0-1";
var second = "0:1.0-2";
Assert.True(DebianEvrComparer.Instance.Compare(second, first) > 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-an-evr";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}

View File

@@ -1,85 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.MergeEvents;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class MergeEventWriterTests
{
[Fact]
public async Task AppendAsync_WritesRecordWithComputedHashes()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var before = CreateAdvisory("CVE-2024-0001", "Initial");
var after = CreateAdvisory("CVE-2024-0001", "Sample", summary: "Updated");
var documentIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.MergeEvents;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class MergeEventWriterTests
{
[Fact]
public async Task AppendAsync_WritesRecordWithComputedHashes()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var before = CreateAdvisory("CVE-2024-0001", "Initial");
var after = CreateAdvisory("CVE-2024-0001", "Sample", summary: "Updated");
var documentIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
var record = await writer.AppendAsync("CVE-2024-0001", before, after, documentIds, Array.Empty<MergeFieldDecision>(), CancellationToken.None);
Assert.NotEqual(Guid.Empty, record.Id);
Assert.Equal("CVE-2024-0001", record.AdvisoryKey);
Assert.True(record.AfterHash.Length > 0);
Assert.Equal(timeProvider.GetUtcNow(), record.MergedAt);
Assert.Equal(documentIds, record.InputDocumentIds);
Assert.NotNull(store.LastRecord);
Assert.Same(store.LastRecord, record);
}
[Fact]
public async Task AppendAsync_NullBeforeUsesEmptyHash()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var after = CreateAdvisory("CVE-2024-0002", "Changed");
Assert.NotEqual(Guid.Empty, record.Id);
Assert.Equal("CVE-2024-0001", record.AdvisoryKey);
Assert.True(record.AfterHash.Length > 0);
Assert.Equal(timeProvider.GetUtcNow(), record.MergedAt);
Assert.Equal(documentIds, record.InputDocumentIds);
Assert.NotNull(store.LastRecord);
Assert.Same(store.LastRecord, record);
}
[Fact]
public async Task AppendAsync_NullBeforeUsesEmptyHash()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var after = CreateAdvisory("CVE-2024-0002", "Changed");
var record = await writer.AppendAsync("CVE-2024-0002", null, after, Array.Empty<Guid>(), Array.Empty<MergeFieldDecision>(), CancellationToken.None);
Assert.Empty(record.BeforeHash);
Assert.True(record.AfterHash.Length > 0);
}
private static Advisory CreateAdvisory(string advisoryKey, string title, string? summary = null)
{
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "medium",
exploitKnown: false,
aliases: new[] { advisoryKey },
references: new[]
{
new AdvisoryReference("https://example.com/" + advisoryKey.ToLowerInvariant(), "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
}
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)
=> Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
}
}
Assert.Empty(record.BeforeHash);
Assert.True(record.AfterHash.Length > 0);
}
private static Advisory CreateAdvisory(string advisoryKey, string title, string? summary = null)
{
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "medium",
exploitKnown: false,
aliases: new[] { advisoryKey },
references: new[]
{
new AdvisoryReference("https://example.com/" + advisoryKey.ToLowerInvariant(), "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
}
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)
=> Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
}
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
@@ -15,69 +15,69 @@ public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
private MergeEventWriter? _mergeEventWriter;
private AdvisoryPrecedenceMerger? _merger;
private FakeTimeProvider? _timeProvider;
[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 }).Advisory;
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, Array.Empty<MergeFieldDecision>(), 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);
[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 }).Advisory;
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, Array.Empty<MergeFieldDecision>(), 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();
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
}
public Task DisposeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private async Task EnsureInitializedAsync()
{
if (_mergeEventWriter is null)
@@ -85,115 +85,115 @@ public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
await InitializeAsync();
}
}
private Task DropMergeCollectionAsync()
{
{
return Task.CompletedTask;
// {
// await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.MergeEvent);
// }
// catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
// catch (StorageCommandException 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<AffectedPackageStatus>(),
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<AffectedVersionRange>(),
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<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
}
// 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<AffectedPackageStatus>(),
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<AffectedVersionRange>(),
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<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { provenance });
}
}

View File

@@ -1,56 +1,56 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
namespace StellaOps.Concelier.Merge.Tests;
internal sealed class MetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly List<MetricMeasurement> _measurements = new();
public MetricCollector(string meterName)
{
if (string.IsNullOrWhiteSpace(meterName))
{
throw new ArgumentException("Meter name is required", nameof(meterName));
}
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagArray = new KeyValuePair<string, object?>[tags.Length];
for (var i = 0; i < tags.Length; i++)
{
tagArray[i] = tags[i];
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagArray));
});
_listener.Start();
}
public IReadOnlyList<MetricMeasurement> Measurements => _measurements;
public void Dispose()
{
_listener.Dispose();
}
internal sealed record MetricMeasurement(
string Name,
long Value,
IReadOnlyList<KeyValuePair<string, object?>> Tags);
}
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
namespace StellaOps.Concelier.Merge.Tests;
internal sealed class MetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly List<MetricMeasurement> _measurements = new();
public MetricCollector(string meterName)
{
if (string.IsNullOrWhiteSpace(meterName))
{
throw new ArgumentException("Meter name is required", nameof(meterName));
}
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagArray = new KeyValuePair<string, object?>[tags.Length];
for (var i = 0; i < tags.Length; i++)
{
tagArray[i] = tags[i];
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagArray));
});
_listener.Start();
}
public IReadOnlyList<MetricMeasurement> Measurements => _measurements;
public void Dispose()
{
_listener.Dispose();
}
internal sealed record MetricMeasurement(
string Name,
long Value,
IReadOnlyList<KeyValuePair<string, object?>> Tags);
}

View File

@@ -1,108 +1,108 @@
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class NevraComparerTests
{
[Theory]
[InlineData("kernel-1:4.18.0-348.7.1.el8_5.x86_64", "kernel", 1, "4.18.0", "348.7.1.el8_5", "x86_64")]
[InlineData("bash-5.1.8-2.fc35.x86_64", "bash", 0, "5.1.8", "2.fc35", "x86_64")]
[InlineData("openssl-libs-1:1.1.1k-7.el8", "openssl-libs", 1, "1.1.1k", "7.el8", null)]
[InlineData("java-11-openjdk-1:11.0.23.0.9-2.el9_4.ppc64le", "java-11-openjdk", 1, "11.0.23.0.9", "2.el9_4", "ppc64le")]
[InlineData("bash-0:5.2.15-3.el9_4.arm64", "bash", 0, "5.2.15", "3.el9_4", "arm64")]
[InlineData("podman-3:4.9.3-1.el9.x86_64", "podman", 3, "4.9.3", "1.el9", "x86_64")]
public void TryParse_ReturnsExpectedComponents(string input, string expectedName, int expectedEpoch, string expectedVersion, string expectedRelease, string? expectedArch)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal(expectedName, nevra!.Name);
Assert.Equal(expectedEpoch, nevra.Epoch);
Assert.Equal(expectedVersion, nevra.Version);
Assert.Equal(expectedRelease, nevra.Release);
Assert.Equal(expectedArch, nevra.Architecture);
Assert.Equal(input, nevra.Original);
}
[Theory]
[InlineData("")]
[InlineData("kernel4.18.0-80.el8")]
[InlineData("kernel-4.18.0")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.False(success);
Assert.Null(nevra);
}
[Fact]
public void TryParse_TrimsWhitespace()
{
var success = Nevra.TryParse(" kernel-0:4.18.0-80.el8.x86_64 ", out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal("kernel", nevra!.Name);
Assert.Equal("4.18.0", nevra.Version);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var older = "kernel-0:4.18.0-348.7.1.el8_5.x86_64";
var newer = "kernel-1:4.18.0-348.7.1.el8_5.x86_64";
Assert.True(NevraComparer.Instance.Compare(newer, older) > 0);
Assert.True(NevraComparer.Instance.Compare(older, newer) < 0);
}
[Fact]
public void Compare_UsesRpmVersionOrdering()
{
var lower = "kernel-0:4.18.0-80.el8.x86_64";
var higher = "kernel-0:4.18.11-80.el8.x86_64";
Assert.True(NevraComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesReleaseOrdering()
{
var el8 = "bash-0:5.1.0-1.el8.x86_64";
var el9 = "bash-0:5.1.0-1.el9.x86_64";
Assert.True(NevraComparer.Instance.Compare(el9, el8) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "bash-0:5.1.0~beta-1.fc34.x86_64";
var stable = "bash-0:5.1.0-1.fc34.x86_64";
Assert.True(NevraComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_ConsidersArchitecture()
{
var noarch = "pkg-0:1.0-1.noarch";
var arch = "pkg-0:1.0-1.x86_64";
Assert.True(NevraComparer.Instance.Compare(noarch, arch) < 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-a-nevra";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.Concelier.Normalization.Distro;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class NevraComparerTests
{
[Theory]
[InlineData("kernel-1:4.18.0-348.7.1.el8_5.x86_64", "kernel", 1, "4.18.0", "348.7.1.el8_5", "x86_64")]
[InlineData("bash-5.1.8-2.fc35.x86_64", "bash", 0, "5.1.8", "2.fc35", "x86_64")]
[InlineData("openssl-libs-1:1.1.1k-7.el8", "openssl-libs", 1, "1.1.1k", "7.el8", null)]
[InlineData("java-11-openjdk-1:11.0.23.0.9-2.el9_4.ppc64le", "java-11-openjdk", 1, "11.0.23.0.9", "2.el9_4", "ppc64le")]
[InlineData("bash-0:5.2.15-3.el9_4.arm64", "bash", 0, "5.2.15", "3.el9_4", "arm64")]
[InlineData("podman-3:4.9.3-1.el9.x86_64", "podman", 3, "4.9.3", "1.el9", "x86_64")]
public void TryParse_ReturnsExpectedComponents(string input, string expectedName, int expectedEpoch, string expectedVersion, string expectedRelease, string? expectedArch)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal(expectedName, nevra!.Name);
Assert.Equal(expectedEpoch, nevra.Epoch);
Assert.Equal(expectedVersion, nevra.Version);
Assert.Equal(expectedRelease, nevra.Release);
Assert.Equal(expectedArch, nevra.Architecture);
Assert.Equal(input, nevra.Original);
}
[Theory]
[InlineData("")]
[InlineData("kernel4.18.0-80.el8")]
[InlineData("kernel-4.18.0")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.False(success);
Assert.Null(nevra);
}
[Fact]
public void TryParse_TrimsWhitespace()
{
var success = Nevra.TryParse(" kernel-0:4.18.0-80.el8.x86_64 ", out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal("kernel", nevra!.Name);
Assert.Equal("4.18.0", nevra.Version);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var older = "kernel-0:4.18.0-348.7.1.el8_5.x86_64";
var newer = "kernel-1:4.18.0-348.7.1.el8_5.x86_64";
Assert.True(NevraComparer.Instance.Compare(newer, older) > 0);
Assert.True(NevraComparer.Instance.Compare(older, newer) < 0);
}
[Fact]
public void Compare_UsesRpmVersionOrdering()
{
var lower = "kernel-0:4.18.0-80.el8.x86_64";
var higher = "kernel-0:4.18.11-80.el8.x86_64";
Assert.True(NevraComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesReleaseOrdering()
{
var el8 = "bash-0:5.1.0-1.el8.x86_64";
var el9 = "bash-0:5.1.0-1.el9.x86_64";
Assert.True(NevraComparer.Instance.Compare(el9, el8) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "bash-0:5.1.0~beta-1.fc34.x86_64";
var stable = "bash-0:5.1.0-1.fc34.x86_64";
Assert.True(NevraComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_ConsidersArchitecture()
{
var noarch = "pkg-0:1.0-1.noarch";
var arch = "pkg-0:1.0-1.x86_64";
Assert.True(NevraComparer.Instance.Compare(noarch, arch) < 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-a-nevra";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}

View File

@@ -1,67 +1,67 @@
using StellaOps.Concelier.Merge.Comparers;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class SemanticVersionRangeResolverTests
{
[Theory]
[InlineData("1.2.3", true)]
[InlineData("1.2.3-beta.1", true)]
[InlineData("invalid", false)]
[InlineData(null, false)]
public void TryParse_ReturnsExpected(string? input, bool expected)
{
var success = SemanticVersionRangeResolver.TryParse(input, out var version);
Assert.Equal(expected, success);
Assert.Equal(expected, version is not null);
}
[Fact]
public void Compare_ParsesSemanticVersions()
{
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3", "1.2.2") > 0);
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3-beta", "1.2.3") < 0);
}
[Fact]
public void Compare_UsesOrdinalFallbackForInvalid()
{
var left = "zzz";
var right = "aaa";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(SemanticVersionRangeResolver.Compare(left, right));
Assert.Equal(expected, actual);
}
[Fact]
public void ResolveWindows_WithFixedVersion_ComputesExclusiveUpper()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", "1.2.0", null);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.2.0"), exclusive);
Assert.Null(inclusive);
}
[Fact]
public void ResolveWindows_WithLastAffectedOnly_ComputesInclusiveAndExclusive()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", null, "1.1.5");
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.6"), exclusive);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.5"), inclusive);
}
[Fact]
public void ResolveWindows_WithNeither_ReturnsNullBounds()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows(null, null, null);
Assert.Null(introduced);
Assert.Null(exclusive);
Assert.Null(inclusive);
}
}
using StellaOps.Concelier.Merge.Comparers;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class SemanticVersionRangeResolverTests
{
[Theory]
[InlineData("1.2.3", true)]
[InlineData("1.2.3-beta.1", true)]
[InlineData("invalid", false)]
[InlineData(null, false)]
public void TryParse_ReturnsExpected(string? input, bool expected)
{
var success = SemanticVersionRangeResolver.TryParse(input, out var version);
Assert.Equal(expected, success);
Assert.Equal(expected, version is not null);
}
[Fact]
public void Compare_ParsesSemanticVersions()
{
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3", "1.2.2") > 0);
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3-beta", "1.2.3") < 0);
}
[Fact]
public void Compare_UsesOrdinalFallbackForInvalid()
{
var left = "zzz";
var right = "aaa";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(SemanticVersionRangeResolver.Compare(left, right));
Assert.Equal(expected, actual);
}
[Fact]
public void ResolveWindows_WithFixedVersion_ComputesExclusiveUpper()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", "1.2.0", null);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.2.0"), exclusive);
Assert.Null(inclusive);
}
[Fact]
public void ResolveWindows_WithLastAffectedOnly_ComputesInclusiveAndExclusive()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", null, "1.1.5");
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.6"), exclusive);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.5"), inclusive);
}
[Fact]
public void ResolveWindows_WithNeither_ReturnsNullBounds()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows(null, null, null);
Assert.Null(introduced);
Assert.Null(exclusive);
Assert.Null(inclusive);
}
}

View File

@@ -1,52 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Merge.Tests;
internal sealed class TestLogger<T> : ILogger<T>
{
private static readonly IDisposable NoopScope = new DisposableScope();
public List<LogEntry> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
=> NoopScope;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter is null)
{
throw new ArgumentNullException(nameof(formatter));
}
IReadOnlyList<KeyValuePair<string, object?>>? structuredState = null;
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
{
structuredState = list.ToArray();
}
else if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
{
structuredState = enumerable.ToArray();
}
Entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception), structuredState));
}
internal sealed record LogEntry(
LogLevel Level,
EventId EventId,
string Message,
IReadOnlyList<KeyValuePair<string, object?>>? StructuredState);
private sealed class DisposableScope : IDisposable
{
public void Dispose()
{
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.Merge.Tests;
internal sealed class TestLogger<T> : ILogger<T>
{
private static readonly IDisposable NoopScope = new DisposableScope();
public List<LogEntry> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
=> NoopScope;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter is null)
{
throw new ArgumentNullException(nameof(formatter));
}
IReadOnlyList<KeyValuePair<string, object?>>? structuredState = null;
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
{
structuredState = list.ToArray();
}
else if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
{
structuredState = enumerable.ToArray();
}
Entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception), structuredState));
}
internal sealed record LogEntry(
LogLevel Level,
EventId EventId,
string Message,
IReadOnlyList<KeyValuePair<string, object?>>? StructuredState);
private sealed class DisposableScope : IDisposable
{
public void Dispose()
{
}
}
}