audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -0,0 +1,285 @@
using StellaOps.Concelier.BackportProof.Models;
using Xunit;
namespace StellaOps.Concelier.BackportProof.Tests;
/// <summary>
/// Tests for PackageEcosystem enum.
/// </summary>
public sealed class PackageEcosystemTests
{
[Theory]
[InlineData(PackageEcosystem.Deb)]
[InlineData(PackageEcosystem.Rpm)]
[InlineData(PackageEcosystem.Apk)]
[InlineData(PackageEcosystem.Unknown)]
public void PackageEcosystem_AllValues_AreDefined(PackageEcosystem ecosystem)
{
Assert.True(Enum.IsDefined(ecosystem));
}
[Fact]
public void PackageEcosystem_AllValues_AreCounted()
{
var values = Enum.GetValues<PackageEcosystem>();
Assert.Equal(4, values.Length);
}
}
/// <summary>
/// Tests for ProductContext record.
/// </summary>
public sealed class ProductContextTests
{
[Fact]
public void ProductContext_RequiredProperties_MustBeSet()
{
var context = new ProductContext(
Distro: "debian",
Release: "bookworm",
RepoScope: "main",
Architecture: "amd64");
Assert.Equal("debian", context.Distro);
Assert.Equal("bookworm", context.Release);
Assert.Equal("main", context.RepoScope);
Assert.Equal("amd64", context.Architecture);
}
[Fact]
public void ProductContext_OptionalProperties_CanBeNull()
{
var context = new ProductContext(
Distro: "alpine",
Release: "3.19",
RepoScope: null,
Architecture: null);
Assert.Null(context.RepoScope);
Assert.Null(context.Architecture);
}
[Fact]
public void ProductContext_RecordEquality_WorksCorrectly()
{
var c1 = new ProductContext("rhel", "9", "main", "x86_64");
var c2 = new ProductContext("rhel", "9", "main", "x86_64");
Assert.Equal(c1, c2);
}
}
/// <summary>
/// Tests for PackageKey record.
/// </summary>
public sealed class PackageKeyTests
{
[Fact]
public void PackageKey_RequiredProperties_MustBeSet()
{
var key = new PackageKey(
Ecosystem: PackageEcosystem.Deb,
PackageName: "nginx",
SourcePackageName: "nginx");
Assert.Equal(PackageEcosystem.Deb, key.Ecosystem);
Assert.Equal("nginx", key.PackageName);
Assert.Equal("nginx", key.SourcePackageName);
}
[Fact]
public void PackageKey_SourcePackage_CanBeNull()
{
var key = new PackageKey(
Ecosystem: PackageEcosystem.Rpm,
PackageName: "httpd",
SourcePackageName: null);
Assert.Null(key.SourcePackageName);
}
}
/// <summary>
/// Tests for EvidenceTier enum.
/// </summary>
public sealed class EvidenceTierTests
{
[Theory]
[InlineData(EvidenceTier.Unknown, 0)]
[InlineData(EvidenceTier.NvdRange, 5)]
[InlineData(EvidenceTier.UpstreamCommit, 4)]
[InlineData(EvidenceTier.SourcePatch, 3)]
[InlineData(EvidenceTier.Changelog, 2)]
[InlineData(EvidenceTier.DistroOval, 1)]
public void EvidenceTier_Values_HaveCorrectNumericValue(EvidenceTier tier, int expected)
{
Assert.Equal(expected, (int)tier);
}
[Fact]
public void EvidenceTier_DistroOval_IsHighestConfidence()
{
// Tier 1 is highest confidence (lowest numeric value)
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
var highestConfidence = allTiers.OrderBy(t => (int)t).First();
Assert.Equal(EvidenceTier.DistroOval, highestConfidence);
}
[Fact]
public void EvidenceTier_NvdRange_IsLowestConfidence()
{
// Tier 5 is lowest confidence (highest numeric value)
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
var lowestConfidence = allTiers.OrderByDescending(t => (int)t).First();
Assert.Equal(EvidenceTier.NvdRange, lowestConfidence);
}
}
/// <summary>
/// Tests for FixStatus enum.
/// </summary>
public sealed class FixStatusTests
{
[Theory]
[InlineData(FixStatus.Patched)]
[InlineData(FixStatus.Vulnerable)]
[InlineData(FixStatus.NotAffected)]
[InlineData(FixStatus.WontFix)]
[InlineData(FixStatus.UnderInvestigation)]
[InlineData(FixStatus.Unknown)]
public void FixStatus_AllValues_AreDefined(FixStatus status)
{
Assert.True(Enum.IsDefined(status));
}
[Fact]
public void FixStatus_AllValues_AreCounted()
{
var values = Enum.GetValues<FixStatus>();
Assert.Equal(6, values.Length);
}
}
/// <summary>
/// Tests for RulePriority enum.
/// </summary>
public sealed class RulePriorityTests
{
[Fact]
public void RulePriority_DistroNativeOval_IsHighestPriority()
{
var allPriorities = Enum.GetValues<RulePriority>();
var highest = allPriorities.Max(p => (int)p);
Assert.Equal((int)RulePriority.DistroNativeOval, highest);
Assert.Equal(100, highest);
}
[Fact]
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
{
var allPriorities = Enum.GetValues<RulePriority>();
var lowest = allPriorities.Min(p => (int)p);
Assert.Equal((int)RulePriority.NvdRangeHeuristic, lowest);
Assert.Equal(20, lowest);
}
[Fact]
public void RulePriority_LegacyAliases_MatchNewValues()
{
Assert.Equal(RulePriority.DistroNativeOval, RulePriority.DistroNative);
Assert.Equal(RulePriority.ChangelogExplicitCve, RulePriority.VendorCsaf);
Assert.Equal(RulePriority.NvdRangeHeuristic, RulePriority.ThirdParty);
}
[Theory]
[InlineData(RulePriority.NvdRangeHeuristic, 20)]
[InlineData(RulePriority.UpstreamCommitPartialMatch, 45)]
[InlineData(RulePriority.UpstreamCommitExactParity, 55)]
[InlineData(RulePriority.SourcePatchFuzzyMatch, 60)]
[InlineData(RulePriority.SourcePatchExactMatch, 70)]
[InlineData(RulePriority.ChangelogBugIdMapped, 75)]
[InlineData(RulePriority.ChangelogExplicitCve, 85)]
[InlineData(RulePriority.DerivativeOvalMedium, 90)]
[InlineData(RulePriority.DerivativeOvalHigh, 95)]
[InlineData(RulePriority.DistroNativeOval, 100)]
public void RulePriority_Values_HaveCorrectNumericValue(RulePriority priority, int expected)
{
Assert.Equal(expected, (int)priority);
}
}
/// <summary>
/// Tests for EvidencePointer record.
/// </summary>
public sealed class EvidencePointerTests
{
[Fact]
public void EvidencePointer_RequiredProperties_MustBeSet()
{
var fetchedAt = DateTimeOffset.UtcNow;
var pointer = new EvidencePointer(
SourceType: "debian-tracker",
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0001",
SourceDigest: "sha256:abc123",
FetchedAt: fetchedAt,
TierSource: EvidenceTier.DistroOval);
Assert.Equal("debian-tracker", pointer.SourceType);
Assert.Equal("https://security-tracker.debian.org/tracker/CVE-2024-0001", pointer.SourceUrl);
Assert.Equal("sha256:abc123", pointer.SourceDigest);
Assert.Equal(fetchedAt, pointer.FetchedAt);
Assert.Equal(EvidenceTier.DistroOval, pointer.TierSource);
}
[Fact]
public void EvidencePointer_TierSource_DefaultsToUnknown()
{
var pointer = new EvidencePointer(
SourceType: "nvd",
SourceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2024-0001",
SourceDigest: null,
FetchedAt: DateTimeOffset.UtcNow);
Assert.Equal(EvidenceTier.Unknown, pointer.TierSource);
}
}
/// <summary>
/// Tests for VersionRange record.
/// </summary>
public sealed class VersionRangeTests
{
[Fact]
public void VersionRange_FullRange_ContainsAllBoundaries()
{
var range = new VersionRange(
MinVersion: "1.0.0",
MinInclusive: true,
MaxVersion: "2.0.0",
MaxInclusive: false);
Assert.Equal("1.0.0", range.MinVersion);
Assert.True(range.MinInclusive);
Assert.Equal("2.0.0", range.MaxVersion);
Assert.False(range.MaxInclusive);
}
[Fact]
public void VersionRange_OpenEnded_AllowsNullBoundaries()
{
// All versions up to 2.0.0 (exclusive)
var range = new VersionRange(
MinVersion: null,
MinInclusive: false,
MaxVersion: "2.0.0",
MaxInclusive: false);
Assert.Null(range.MinVersion);
Assert.Equal("2.0.0", range.MaxVersion);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.BackportProof\StellaOps.Concelier.BackportProof.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -387,6 +387,7 @@ public sealed class CanonicalMergerTests
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null,
IEnumerable<AdvisoryReference>? references = null,
IEnumerable<AdvisoryCredit>? credits = null,
IEnumerable<AdvisoryWeakness>? weaknesses = null,
string? canonicalMetricId = null)
{
@@ -407,7 +408,7 @@ public sealed class CanonicalMergerTests
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
credits: credits ?? Array.Empty<AdvisoryCredit>(),
references: references ?? Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),

View File

@@ -0,0 +1,550 @@
// -----------------------------------------------------------------------------
// SourceRegistryTests.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: Unit tests for Source Registry
// Description: Unit tests for the SourceRegistry implementation
// -----------------------------------------------------------------------------
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Moq.Protected;
using StellaOps.Concelier.Core.Configuration;
using StellaOps.Concelier.Core.Sources;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Sources;
[Trait("Category", "Unit")]
public sealed class SourceRegistryTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
private SourceRegistry CreateRegistry(SourcesConfiguration? configuration = null)
{
return new SourceRegistry(
_httpClientFactoryMock.Object,
NullLogger<SourceRegistry>.Instance,
_timeProvider,
configuration);
}
#region GetAllSources Tests
[Fact]
public void GetAllSources_ReturnsAllDefinedSources()
{
var registry = CreateRegistry();
var sources = registry.GetAllSources();
Assert.NotEmpty(sources);
Assert.True(sources.Count >= 30, "Expected at least 30 sources defined");
}
[Fact]
public void GetAllSources_ContainsExpectedSources()
{
var registry = CreateRegistry();
var sources = registry.GetAllSources();
var sourceIds = sources.Select(s => s.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("nvd", sourceIds);
Assert.Contains("ghsa", sourceIds);
Assert.Contains("osv", sourceIds);
Assert.Contains("epss", sourceIds);
Assert.Contains("kev", sourceIds);
}
#endregion
#region GetSource Tests
[Fact]
public void GetSource_ReturnsSource_ForValidId()
{
var registry = CreateRegistry();
var source = registry.GetSource("nvd");
Assert.NotNull(source);
Assert.Equal("nvd", source.Id);
Assert.Contains("NVD", source.DisplayName);
}
[Fact]
public void GetSource_IsCaseInsensitive()
{
var registry = CreateRegistry();
var source1 = registry.GetSource("NVD");
var source2 = registry.GetSource("nvd");
var source3 = registry.GetSource("Nvd");
Assert.NotNull(source1);
Assert.NotNull(source2);
Assert.NotNull(source3);
Assert.Equal(source1.Id, source2.Id);
Assert.Equal(source2.Id, source3.Id);
}
[Fact]
public void GetSource_ReturnsNull_ForUnknownSource()
{
var registry = CreateRegistry();
var source = registry.GetSource("unknown-source-xyz");
Assert.Null(source);
}
[Fact]
public void GetSource_ThrowsArgumentException_ForNullOrEmpty()
{
var registry = CreateRegistry();
// ArgumentNullException for null, ArgumentException for empty/whitespace
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(null!));
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(""));
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(" "));
}
#endregion
#region GetSourcesByCategory Tests
[Fact]
public void GetSourcesByCategory_ReturnsSourcesInCategory()
{
var registry = CreateRegistry();
var primarySources = registry.GetSourcesByCategory(SourceCategory.Primary);
Assert.NotEmpty(primarySources);
Assert.All(primarySources, s => Assert.Equal(SourceCategory.Primary, s.Category));
}
[Fact]
public void GetSourcesByCategory_ReturnsEmptyList_ForEmptyCategory()
{
var registry = CreateRegistry();
var sources = registry.GetSourcesByCategory(SourceCategory.Other);
// Other category may be empty or contain sources
Assert.NotNull(sources);
}
#endregion
#region EnableSourceAsync/DisableSourceAsync Tests
[Fact]
public async Task EnableSourceAsync_EnablesSource_ReturnsTrue()
{
var registry = CreateRegistry();
await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
var result = await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(result);
Assert.True(registry.IsEnabled("nvd"));
}
[Fact]
public async Task EnableSourceAsync_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.EnableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.False(result);
}
[Fact]
public async Task DisableSourceAsync_DisablesSource_ReturnsTrue()
{
var registry = CreateRegistry();
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
var result = await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(result);
Assert.False(registry.IsEnabled("nvd"));
}
[Fact]
public async Task DisableSourceAsync_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.DisableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.False(result);
}
#endregion
#region GetEnabledSourcesAsync Tests
[Fact]
public async Task GetEnabledSourcesAsync_ReturnsEnabledSources()
{
var registry = CreateRegistry();
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
await registry.EnableSourceAsync("ghsa", TestContext.Current.CancellationToken);
await registry.DisableSourceAsync("osv", TestContext.Current.CancellationToken);
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
Assert.Contains("nvd", enabled, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ghsa", enabled, StringComparer.OrdinalIgnoreCase);
}
#endregion
#region IsEnabled Tests
[Fact]
public void IsEnabled_ReturnsTrue_ForEnabledSource()
{
var registry = CreateRegistry();
// By default, most sources should be enabled
var isEnabled = registry.IsEnabled("nvd");
// NVD is enabled by default
Assert.True(isEnabled);
}
[Fact]
public void IsEnabled_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var isEnabled = registry.IsEnabled("unknown-source-xyz");
Assert.False(isEnabled);
}
#endregion
#region CheckConnectivityAsync Tests
[Fact]
public async Task CheckConnectivityAsync_ReturnsNotFound_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.Equal("SOURCE_NOT_FOUND", result.ErrorCode);
Assert.False(result.IsHealthy);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsHealthy_ForSuccessfulResponse()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Healthy, result.Status);
Assert.True(result.IsHealthy);
Assert.NotNull(result.Latency);
Assert.Equal(FixedNow, result.CheckedAt);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsFailed_ForHttpError()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.False(result.IsHealthy);
Assert.Equal(401, result.HttpStatusCode);
Assert.NotEmpty(result.PossibleReasons);
Assert.NotEmpty(result.RemediationSteps);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsFailed_ForNetworkError()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.False(result.IsHealthy);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task CheckConnectivityAsync_UsesTimeProvider()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(FixedNow, result.CheckedAt);
}
[Fact]
public async Task CheckConnectivityAsync_StoresLastResult()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
var lastResult = registry.GetLastCheckResult("nvd");
Assert.NotNull(lastResult);
Assert.Equal("nvd", lastResult.SourceId);
Assert.Equal(SourceConnectivityStatus.Healthy, lastResult.Status);
}
#endregion
#region CheckAllAndAutoConfigureAsync Tests
[Fact]
public async Task CheckAllAndAutoConfigureAsync_ChecksAllSources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(result.Results);
Assert.Equal(registry.GetAllSources().Count, result.TotalChecked);
Assert.True(result.AllHealthy);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_ReturnsAggregatedResult()
{
var callCount = 0;
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
Interlocked.Increment(ref callCount);
// Make some requests fail
return callCount % 5 == 0
? new HttpResponseMessage(HttpStatusCode.InternalServerError)
: new HttpResponseMessage(HttpStatusCode.OK);
});
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(result.Results);
Assert.True(result.HealthyCount > 0);
Assert.True(result.FailedCount > 0);
Assert.False(result.AllHealthy);
Assert.True(result.HasFailures);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_AutoEnablesHealthySources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var configuration = new SourcesConfiguration { AutoEnableHealthySources = true };
var registry = CreateRegistry(configuration);
// Disable all sources first
foreach (var source in registry.GetAllSources())
{
await registry.DisableSourceAsync(source.Id, TestContext.Current.CancellationToken);
}
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
// All healthy sources should now be enabled
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
Assert.Equal(result.HealthyCount, enabled.Length);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_RecordsDuration()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.True(result.TotalDuration >= TimeSpan.Zero);
Assert.Equal(FixedNow, result.CheckedAt);
}
#endregion
#region CheckMultipleAsync Tests
[Fact]
public async Task CheckMultipleAsync_ChecksSpecifiedSources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var sourceIds = new[] { "nvd", "ghsa", "osv" };
var results = await registry.CheckMultipleAsync(sourceIds, TestContext.Current.CancellationToken);
Assert.Equal(3, results.Length);
Assert.All(results, r => Assert.True(r.IsHealthy));
}
#endregion
#region RetryCheckAsync Tests
[Fact]
public async Task RetryCheckAsync_RetriesConnectivityCheck()
{
var callCount = 0;
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
Interlocked.Increment(ref callCount);
// Fail first time, succeed second time
return callCount == 1
? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
: new HttpResponseMessage(HttpStatusCode.OK);
});
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
// First check fails
var firstResult = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.False(firstResult.IsHealthy);
// Retry succeeds
var retryResult = await registry.RetryCheckAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(retryResult.IsHealthy);
}
#endregion
}

View File

@@ -0,0 +1,290 @@
using StellaOps.Attestor.ProofChain.Models;
using System.Text.Json;
using Xunit;
namespace StellaOps.Concelier.ProofService.Tests;
/// <summary>
/// Tests for ProofEvidence and related models used by BackportProofService.
/// </summary>
public sealed class ProofEvidenceModelTests
{
[Fact]
public void ProofEvidence_RequiredProperties_MustBeSet()
{
var dataJson = JsonSerializer.SerializeToElement(new { cve = "CVE-2026-0001", severity = "HIGH" });
var evidence = new ProofEvidence
{
EvidenceId = "evidence:distro:debian:DSA-1234",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:abc123def456"
};
Assert.Equal("evidence:distro:debian:DSA-1234", evidence.EvidenceId);
Assert.Equal(EvidenceType.DistroAdvisory, evidence.Type);
Assert.Equal("debian", evidence.Source);
Assert.Equal("sha256:abc123def456", evidence.DataHash);
}
[Theory]
[InlineData(EvidenceType.DistroAdvisory)]
[InlineData(EvidenceType.ChangelogMention)]
[InlineData(EvidenceType.PatchHeader)]
[InlineData(EvidenceType.BinaryFingerprint)]
[InlineData(EvidenceType.VersionComparison)]
[InlineData(EvidenceType.BuildCatalog)]
public void ProofEvidence_Type_AllValues_AreValid(EvidenceType type)
{
var dataJson = JsonSerializer.SerializeToElement(new { test = true });
var evidence = new ProofEvidence
{
EvidenceId = $"evidence:{type.ToString().ToLowerInvariant()}:test",
Type = type,
Source = "test-source",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:test"
};
Assert.Equal(type, evidence.Type);
}
[Fact]
public void ProofEvidence_DataJson_ContainsStructuredData()
{
var advisoryData = new
{
distro = "ubuntu",
advisory_id = "USN-1234-1",
packages = new[] { "libcurl4", "curl" },
fixed_version = "7.68.0-1ubuntu2.15"
};
var dataJson = JsonSerializer.SerializeToElement(advisoryData);
var evidence = new ProofEvidence
{
EvidenceId = "evidence:distro:ubuntu:USN-1234-1",
Type = EvidenceType.DistroAdvisory,
Source = "ubuntu",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:structured123"
};
Assert.Equal(JsonValueKind.Object, evidence.Data.ValueKind);
Assert.Equal("ubuntu", evidence.Data.GetProperty("distro").GetString());
}
[Fact]
public void ProofEvidence_RecordEquality_WorksCorrectly()
{
var timestamp = DateTimeOffset.UtcNow;
var dataJson = JsonSerializer.SerializeToElement(new { key = "value" });
var evidence1 = new ProofEvidence
{
EvidenceId = "evidence:test:eq",
Type = EvidenceType.ChangelogMention,
Source = "changelog",
Timestamp = timestamp,
Data = dataJson,
DataHash = "sha256:equal"
};
var evidence2 = new ProofEvidence
{
EvidenceId = "evidence:test:eq",
Type = EvidenceType.ChangelogMention,
Source = "changelog",
Timestamp = timestamp,
Data = dataJson,
DataHash = "sha256:equal"
};
Assert.Equal(evidence1, evidence2);
}
}
/// <summary>
/// Tests for ProofBlob model.
/// </summary>
public sealed class ProofBlobModelTests
{
[Fact]
public void ProofBlob_RequiredProperties_MustBeSet()
{
var evidences = new List<ProofEvidence>
{
new()
{
EvidenceId = "evidence:test:001",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = JsonSerializer.SerializeToElement(new { }),
DataHash = "sha256:ev1"
}
};
var proof = new ProofBlob
{
ProofId = "sha256:proof123",
SubjectId = "CVE-2026-0001:pkg:deb/debian/curl@7.64.0",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = evidences,
Method = "distro_advisory",
Confidence = 0.95,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:2026-01-14"
};
Assert.Equal("sha256:proof123", proof.ProofId);
Assert.Equal("CVE-2026-0001:pkg:deb/debian/curl@7.64.0", proof.SubjectId);
Assert.Equal(ProofBlobType.BackportFixed, proof.Type);
Assert.Equal(0.95, proof.Confidence);
}
[Fact]
public void ProofBlob_WithMultipleEvidences_ContainsAll()
{
var dataJson = JsonSerializer.SerializeToElement(new { });
var evidences = new List<ProofEvidence>
{
new()
{
EvidenceId = "evidence:distro:dsa",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:dsa"
},
new()
{
EvidenceId = "evidence:changelog:debian",
Type = EvidenceType.ChangelogMention,
Source = "debian-changelog",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:changelog"
},
new()
{
EvidenceId = "evidence:patch:fix",
Type = EvidenceType.PatchHeader,
Source = "git-patch",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:patch"
}
};
var proof = new ProofBlob
{
ProofId = "sha256:multiproof",
SubjectId = "CVE-2026-0002:pkg:npm/lodash@4.17.20",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = evidences,
Method = "combined",
Confidence = 0.92,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:2026-01-14"
};
Assert.Equal(3, proof.Evidences.Count);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.DistroAdvisory);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.ChangelogMention);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.PatchHeader);
}
[Theory]
[InlineData(ProofBlobType.BackportFixed)]
[InlineData(ProofBlobType.NotAffected)]
[InlineData(ProofBlobType.Vulnerable)]
[InlineData(ProofBlobType.Unknown)]
public void ProofBlob_Type_AllValues_AreValid(ProofBlobType type)
{
var proof = new ProofBlob
{
ProofId = $"sha256:{type.ToString().ToLowerInvariant()}",
SubjectId = "CVE-2026-TYPE:pkg:test/pkg@1.0.0",
Type = type,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.5,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Equal(type, proof.Type);
}
[Fact]
public void ProofBlob_Confidence_InValidRange()
{
var proof = new ProofBlob
{
ProofId = "sha256:conf",
SubjectId = "CVE-2026-CONF:pkg:test/pkg@1.0.0",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.87,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.InRange(proof.Confidence, 0.0, 1.0);
}
[Fact]
public void ProofBlob_ProofHash_IsOptional()
{
var proofWithoutHash = new ProofBlob
{
ProofId = "sha256:nohash",
SubjectId = "CVE-2026-NH:pkg:test/pkg@1.0.0",
Type = ProofBlobType.Unknown,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.0,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Null(proofWithoutHash.ProofHash);
var proofWithHash = proofWithoutHash with { ProofHash = "sha256:computed" };
Assert.Equal("sha256:computed", proofWithHash.ProofHash);
}
[Fact]
public void ProofBlob_SubjectId_ContainsCveAndPurl()
{
var proof = new ProofBlob
{
ProofId = "sha256:subject",
SubjectId = "CVE-2026-12345:pkg:pypi/django@4.2.0",
Type = ProofBlobType.NotAffected,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "vex",
Confidence = 1.0,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Contains("CVE-2026-12345", proof.SubjectId);
Assert.Contains("pkg:pypi/django@4.2.0", proof.SubjectId);
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}