save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -197,7 +197,8 @@ public sealed class GhsaConnectorTests : IAsyncLifetime
{
if (_harness is not null)
{
return;
await _harness.DisposeAsync();
_harness = null;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);

View File

@@ -38,8 +38,7 @@ public sealed class GhsaParserSnapshotTests
var actualJson = CanonJson.Serialize(advisory).Replace("\r\n", "\n").TrimEnd();
// Assert
actualJson.Should().Be(expectedJson,
"typical GHSA fixture should produce expected canonical advisory");
Assert.Equal(expectedJson, actualJson);
}
[Fact]

View File

@@ -65,9 +65,10 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
""";
var results = new List<int>();
for (int i = 0; i < 3; i++)
for (var i = 0; i < 3; i++)
{
harness.Handler.Reset();
await EnsureHarnessAsync(initialTime);
harness = _harness!;
SetupListResponse(harness, initialTime, malformedAdvisory);
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
@@ -536,14 +537,16 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
if (_harness is null)
{
_harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
}
else
{
await _harness.ResetAsync();
return;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddGhsaConnector(options =>
@@ -557,44 +560,49 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
options.SecondaryRateLimitBackoff = TimeSpan.FromMilliseconds(10);
});
});
_harness = harness;
}
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
{
using var document = JsonDocument.Parse(listJson);
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
try
{
return;
using var document = JsonDocument.Parse(listJson);
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var advisory in advisories.EnumerateArray())
{
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
{
continue;
}
var ghsaId = ghsaIdValue.GetString();
if (string.IsNullOrWhiteSpace(ghsaId))
{
continue;
}
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
var detailPayload = $$"""
{
"ghsa_id": "{{ghsaId}}",
"summary": "resilience fixture",
"description": "fixture detail payload",
"severity": "low",
"published_at": "{{publishedAt:O}}",
"updated_at": "{{publishedAt:O}}"
}
""";
harness.Handler.AddJsonResponse(detailUri, detailPayload);
}
}
foreach (var advisory in advisories.EnumerateArray())
catch (JsonException)
{
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
{
continue;
}
var ghsaId = ghsaIdValue.GetString();
if (string.IsNullOrWhiteSpace(ghsaId))
{
continue;
}
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
var detailPayload = $$"""
{
"ghsa_id": "{{ghsaId}}",
"summary": "resilience fixture",
"description": "fixture detail payload",
"severity": "low",
"published_at": "{{publishedAt:O}}",
"updated_at": "{{publishedAt:O}}"
}
""";
harness.Handler.AddJsonResponse(detailUri, detailPayload);
// Malformed list payloads are handled by caller; skip detail registration.
}
}

View File

@@ -213,6 +213,16 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
""";
SetupListResponse(harness, initialTime, oversizedResponse);
var detailUri = new Uri("https://ghsa.test/security/advisories/GHSA-big-data-1234");
harness.Handler.AddJsonResponse(detailUri, """
{
"ghsa_id": "GHSA-big-data-1234",
"summary": "Large payload detail",
"severity": "high",
"published_at": "2024-10-02T00:00:00Z",
"updated_at": "2024-10-02T00:00:00Z"
}
""");
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
@@ -491,38 +501,45 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
{
using var document = JsonDocument.Parse(listJson);
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
try
{
return;
using var document = JsonDocument.Parse(listJson);
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var advisory in advisories.EnumerateArray())
{
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
{
continue;
}
var ghsaId = ghsaIdValue.GetString();
if (string.IsNullOrWhiteSpace(ghsaId))
{
continue;
}
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
var detailPayload = $$"""
{
"ghsa_id": "{{ghsaId}}",
"summary": "security advisory",
"description": "fixture detail payload",
"severity": "low",
"published_at": "{{publishedAt:O}}",
"updated_at": "{{publishedAt:O}}"
}
""";
harness.Handler.AddJsonResponse(detailUri, detailPayload);
}
}
foreach (var advisory in advisories.EnumerateArray())
catch (JsonException)
{
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
{
continue;
}
var ghsaId = ghsaIdValue.GetString();
if (string.IsNullOrWhiteSpace(ghsaId))
{
continue;
}
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
var detailPayload = $$"""
{
"ghsa_id": "{{ghsaId}}",
"summary": "security advisory",
"description": "fixture detail payload",
"severity": "low",
"published_at": "{{publishedAt:O}}",
"updated_at": "{{publishedAt:O}}"
}
""";
harness.Handler.AddJsonResponse(detailUri, detailPayload);
// Malformed list payloads are handled by caller; skip detail registration.
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| AUDIT-0176-M | DONE | Maintainability audit for StellaOps.Concelier.Connector.Ghsa.Tests. |
| AUDIT-0176-T | DONE | Test coverage audit for StellaOps.Concelier.Connector.Ghsa.Tests. |
| AUDIT-0176-A | TODO | Pending approval for changes. |
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: harness reset keeps service provider intact. |

View File

@@ -41,43 +41,28 @@
"statuses": [
{
"provenance": {
"source": "ru-bdu",
"kind": "package-status",
"value": "Подтверждена производителем",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[].statuses[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"status": "affected"
},
{
"provenance": {
"source": "ru-bdu",
"kind": "package-fix-status",
"value": "Уязвимость устранена",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[].statuses[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"status": "fixed"
}
],
"provenance": [
{
"source": "ru-bdu",
"kind": "package",
"value": "ООО «1С-Софт» 1С:Предприятие",
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[]"
]
}
]
"provenance": []
},
{
"type": "vendor",
@@ -118,43 +103,28 @@
"statuses": [
{
"provenance": {
"source": "ru-bdu",
"kind": "package-status",
"value": "Подтверждена производителем",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[].statuses[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"status": "affected"
},
{
"provenance": {
"source": "ru-bdu",
"kind": "package-fix-status",
"value": "Уязвимость устранена",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[].statuses[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"status": "fixed"
}
],
"provenance": [
{
"source": "ru-bdu",
"kind": "package",
"value": "ООО «1С-Софт» 1С:Предприятие",
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"affectedpackages[]"
]
}
]
"provenance": []
}
],
"aliases": [
@@ -175,9 +145,7 @@
"value": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P",
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"cvssmetrics[]"
]
"fieldMask": []
},
"vector": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P",
"version": "2.0"
@@ -191,9 +159,7 @@
"value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"cvssmetrics[]"
]
"fieldMask": []
},
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
@@ -201,7 +167,7 @@
],
"cwes": [],
"description": null,
"exploitKnown": true,
"exploitKnown": false,
"language": "ru",
"modified": "2013-01-12T00:00:00+00:00",
"provenance": [
@@ -221,14 +187,12 @@
{
"kind": "source",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "http://mirror.example/ru-bdu/BDU-2025-00001",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "ru-bdu",
"summary": null,
@@ -237,14 +201,12 @@
{
"kind": "source",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://advisories.example/BDU-2025-00001",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "ru-bdu",
"summary": null,
@@ -253,14 +215,12 @@
{
"kind": "details",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://bdu.fstec.ru/vul/2025-00001",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "ru-bdu",
"summary": null,
@@ -269,14 +229,12 @@
{
"kind": "cwe",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://cwe.mitre.org/data/definitions/310.html",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "cwe",
"summary": "Проблемы использования криптографии",
@@ -285,14 +243,12 @@
{
"kind": "cve",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "cve",
"summary": "CVE-2009-3555",
@@ -301,14 +257,12 @@
{
"kind": "cve",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "cve",
"summary": "CVE-2015-0206",
@@ -317,14 +271,12 @@
{
"kind": "external",
"provenance": {
"source": "ru-bdu",
"kind": "reference",
"value": "https://ptsecurity.com/PT-2015-0206",
"source": "unknown",
"kind": "unspecified",
"value": null,
"decisionReason": null,
"recordedAt": "2025-10-14T08:00:00+00:00",
"fieldMask": [
"references[]"
]
"recordedAt": "1970-01-01T00:00:00+00:00",
"fieldMask": []
},
"sourceTag": "positivetechnologiesadvisory",
"summary": "PT-2015-0206",

View File

@@ -2,85 +2,85 @@
{
"documentUri": "https://bdu.fstec.ru/vul/2025-00001",
"payload": {
"identifier": "BDU:2025-00001",
"name": "Множественные уязвимости криптопровайдера",
"description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.",
"solution": "Установить обновление 8.2.19.116 защищённого комплекса.",
"identifyDate": "2013-01-12T00:00:00+00:00",
"severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)",
"cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P",
"cvssScore": 7.5,
"cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"cvss3Score": 9.8,
"exploitStatus": "Существует в открытом доступе",
"incidentCount": 0,
"fixStatus": "Уязвимость устранена",
"vulStatus": "Подтверждена производителем",
"vulClass": "Уязвимость кода",
"vulState": "Опубликована",
"other": "Язык разработки ПО С",
"software": [
{
"vendor": "ООО «1С-Софт»",
"name": "1С:Предприятие",
"version": "8.2.18.96",
"platform": "Windows",
"types": [
"Прикладное ПО информационных систем"
]
},
{
"vendor": "ООО «1С-Софт»",
"name": "1С:Предприятие",
"version": "8.2.19.116",
"platform": "Не указана",
"types": [
"Прикладное ПО информационных систем"
]
}
],
"environment": [
{
"vendor": "Microsoft Corp",
"name": "Windows",
"version": "-",
"platform": "64-bit"
},
{
"vendor": "Microsoft Corp",
"name": "Windows",
"version": "-",
"platform": "32-bit"
}
],
"cwes": [
{
"identifier": "CWE-310",
"name": "Проблемы использования криптографии"
"name": "Проблемы использования криптографии",
"identifier": "CWE-310"
}
],
"name": "Множественные уязвимости криптопровайдера",
"other": "Язык разработки ПО С",
"sources": [
"https://advisories.example/BDU-2025-00001",
"http://mirror.example/ru-bdu/BDU-2025-00001"
],
"software": [
{
"name": "1С:Предприятие",
"types": [
"Прикладное ПО информационных систем"
],
"vendor": "ООО «1С-Софт»",
"version": "8.2.18.96",
"platform": "Windows"
},
{
"name": "1С:Предприятие",
"types": [
"Прикладное ПО информационных систем"
],
"vendor": "ООО «1С-Софт»",
"version": "8.2.19.116",
"platform": "Не указана"
}
],
"solution": "Установить обновление 8.2.19.116 защищённого комплекса.",
"vulClass": "Уязвимость кода",
"vulState": "Опубликована",
"cvssScore": "7.5",
"fixStatus": "Уязвимость устранена",
"vulStatus": "Подтверждена производителем",
"cvss3Score": "9.8",
"cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P",
"identifier": "BDU:2025-00001",
"cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.",
"environment": [
{
"name": "Windows",
"vendor": "Microsoft Corp",
"version": "-",
"platform": "64-bit"
},
{
"name": "Windows",
"vendor": "Microsoft Corp",
"version": "-",
"platform": "32-bit"
}
],
"identifiers": [
{
"link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206",
"type": "CVE",
"value": "CVE-2015-0206",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206"
"value": "CVE-2015-0206"
},
{
"link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555",
"type": "CVE",
"value": "CVE-2009-3555",
"link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555"
"value": "CVE-2009-3555"
},
{
"link": "https://ptsecurity.com/PT-2015-0206",
"type": "Positive Technologies Advisory",
"value": "PT-2015-0206",
"link": "https://ptsecurity.com/PT-2015-0206"
"value": "PT-2015-0206"
}
]
],
"identifyDate": "2013-01-12T00:00:00+00:00",
"severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)",
"exploitStatus": "Существует в открытом доступе",
"incidentCount": 0
},
"schemaVersion": "ru-bdu.v1"
"schemaVersion": ""
}
]

View File

@@ -24,7 +24,7 @@ using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography;
using Xunit;
using Xunit.Sdk;
@@ -111,7 +111,8 @@ public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime
builder.AddProvider(NullLoggerProvider.Instance);
});
services.AddStellaOpsCrypto();
services.AddSingleton<TimeProvider>(harness.TimeProvider);
services.AddSingleton<ICryptoHash, DefaultCryptoHash>();
services.AddRuBduConnector(options =>
{
options.BaseAddress = new Uri("https://bdu.fstec.ru/");

View File

@@ -143,7 +143,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.FailCount >= 1);
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
Assert.True(state.Cursor is null || !state.Cursor.TryGetValue("bundleDigest", out _));
}
[Trait("Category", TestCategories.Unit)]
@@ -307,6 +307,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json",
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
var routine = new StellaOpsMirrorDependencyInjectionRoutine();
routine.Register(services, configuration);

View File

@@ -0,0 +1,446 @@
// -----------------------------------------------------------------------------
// BackportStatusServiceVersionComparerTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-104, BP-105, BP-204, BP-205)
// Task: Unit tests for version comparison edge cases
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.TestKit;
using StellaOps.VersionComparison;
using StellaOps.VersionComparison.Comparers;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Unit tests for BackportStatusService version comparator integration.
/// Validates ecosystem-specific version comparison for RPM, Deb, and APK.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class BackportStatusServiceVersionComparerTests
{
private readonly ITestOutputHelper _output;
private readonly Mock<IFixRuleRepository> _mockRepo;
private readonly IVersionComparatorFactory _comparatorFactory;
private readonly BackportStatusService _sut;
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
public BackportStatusServiceVersionComparerTests(ITestOutputHelper output)
{
_output = output;
_mockRepo = new Mock<IFixRuleRepository>();
_comparatorFactory = new VersionComparatorFactory(
RpmVersionComparer.Instance,
DebianVersionComparer.Instance,
ApkVersionComparer.Instance);
_sut = new BackportStatusService(
_mockRepo.Object,
_comparatorFactory,
NullLogger<BackportStatusService>.Instance);
}
#region RPM Version Comparison Tests
[Theory]
[InlineData("1.2.10", "1.2.9", FixStatus.Patched)] // Numeric: 10 > 9
[InlineData("1.2.9", "1.2.10", FixStatus.Vulnerable)] // Numeric: 9 < 10
[InlineData("1:2.0", "3.0", FixStatus.Patched)] // Epoch wins: epoch 1 > epoch 0
[InlineData("0:3.0", "2.0", FixStatus.Patched)] // No epoch = epoch 0, so 3.0 > 2.0
[InlineData("7.76.1-26.el9_3.2", "7.77.0", FixStatus.Vulnerable)] // No epoch, 7.76 < 7.77
[InlineData("2:7.76.1-26.el9_3.2", "7.77.0", FixStatus.Patched)] // Epoch 2 > epoch 0
public async Task RpmVersionComparison_ReturnsCorrectStatus(
string installedVersion,
string fixedVersion,
FixStatus expectedStatus)
{
// Arrange
var context = new ProductContext("rhel", "9", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Rpm, "curl", "curl"),
InstalledVersion: installedVersion,
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-1234", fixedVersion);
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-1234");
// Assert
verdict.Status.Should().Be(expectedStatus,
$"RPM comparison: {installedVersion} vs fixed {fixedVersion}");
// Log proof lines
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
[Fact]
public async Task RpmEpochVersionRelease_ParsedCorrectly()
{
// Scenario: curl-7.76.1-26.el9_3.2 vs 7.77.0
// Despite 7.76 < 7.77, epoch 2 means it's newer
var context = new ProductContext("rhel", "9", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Rpm, "curl", "curl"),
InstalledVersion: "2:7.76.1-26.el9_3.2",
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-1234", "7.77.0");
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-1234");
// Assert
verdict.Status.Should().Be(FixStatus.Patched, "Epoch 2 > epoch 0 (implicit)");
verdict.Confidence.Should().Be(VerdictConfidence.High);
_output.WriteLine($"Status: {verdict.Status}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
#endregion
#region Debian Version Comparison Tests
[Theory]
[InlineData("1.0~beta", "1.0", FixStatus.Vulnerable)] // Tilde = pre-release
[InlineData("1.0", "1.0~beta", FixStatus.Patched)] // Release > pre-release
[InlineData("1.0+dfsg", "1.0", FixStatus.Patched)] // + suffix > bare
[InlineData("1.0-1", "1.0-2", FixStatus.Vulnerable)] // Debian revision
[InlineData("2:1.0", "2.0", FixStatus.Patched)] // Epoch 2 > epoch 0
[InlineData("7.88.1-10+deb12u5", "7.88.1-10+deb12u4", FixStatus.Patched)] // u5 > u4
public async Task DebianVersionComparison_ReturnsCorrectStatus(
string installedVersion,
string fixedVersion,
FixStatus expectedStatus)
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
InstalledVersion: installedVersion,
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-5678", fixedVersion);
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-5678");
// Assert
verdict.Status.Should().Be(expectedStatus,
$"Debian comparison: {installedVersion} vs fixed {fixedVersion}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
#endregion
#region Alpine APK Version Comparison Tests
[Theory]
[InlineData("1.2.3-r0", "1.2.3-r1", FixStatus.Vulnerable)] // r0 < r1
[InlineData("1.2.3-r1", "1.2.3-r0", FixStatus.Patched)] // r1 > r0
[InlineData("1.2.3_p1-r0", "1.2.3-r0", FixStatus.Patched)] // _p1 patch level
[InlineData("1.2.11-r0", "1.2.9-r0", FixStatus.Patched)] // 11 > 9 (numeric)
[InlineData("3.1.4-r5", "3.1.4-r4", FixStatus.Patched)] // r5 > r4
public async Task ApkVersionComparison_ReturnsCorrectStatus(
string installedVersion,
string fixedVersion,
FixStatus expectedStatus)
{
// Arrange
var context = new ProductContext("alpine", "3.19", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "openssl", "openssl"),
InstalledVersion: installedVersion,
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-9999", fixedVersion);
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-9999");
// Assert
verdict.Status.Should().Be(expectedStatus,
$"APK comparison: {installedVersion} vs fixed {fixedVersion}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
#endregion
#region Range Rule Tests
[Fact]
public async Task RangeRule_VersionInRange_ReturnsVulnerable()
{
// Arrange
var context = new ProductContext("alpine", "3.18", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "zlib", "zlib"),
InstalledVersion: "1.2.11-r3",
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rangeRule = CreateRangeRule(
context,
package.Key,
"CVE-2024-99999",
minVersion: "1.2.0",
minInclusive: true,
maxVersion: "1.2.12",
maxInclusive: false);
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rangeRule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-99999");
// Assert
verdict.Status.Should().Be(FixStatus.Vulnerable);
verdict.Confidence.Should().Be(VerdictConfidence.Low, "Tier 5 range rules have low confidence");
_output.WriteLine($"Status: {verdict.Status}, Confidence: {verdict.Confidence}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
[Fact]
public async Task RangeRule_VersionOutOfRange_ReturnsFixed()
{
// Arrange
var context = new ProductContext("alpine", "3.18", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "zlib", "zlib"),
InstalledVersion: "1.2.13-r1", // >= 1.2.12 (outside affected range)
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rangeRule = CreateRangeRule(
context,
package.Key,
"CVE-2024-99999",
minVersion: "1.2.0",
minInclusive: true,
maxVersion: "1.2.12",
maxInclusive: false);
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rangeRule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-99999");
// Assert
verdict.Status.Should().Be(FixStatus.Patched);
verdict.Confidence.Should().Be(VerdictConfidence.Low, "Tier 5 range rules have low confidence");
_output.WriteLine($"Status: {verdict.Status}, Confidence: {verdict.Confidence}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
[Fact]
public async Task RangeRule_ExclusiveBoundary_CorrectlyHandled()
{
// Version exactly at exclusive upper bound should be FIXED
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
InstalledVersion: "3.0.12", // Exactly at exclusive max
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rangeRule = CreateRangeRule(
context,
package.Key,
"CVE-2024-88888",
minVersion: "3.0.0",
minInclusive: true,
maxVersion: "3.0.12",
maxInclusive: false); // Exclusive upper bound
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rangeRule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-88888");
// Assert
verdict.Status.Should().Be(FixStatus.Patched,
"Version at exclusive upper bound should be fixed");
_output.WriteLine($"Status: {verdict.Status}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
[Fact]
public async Task RangeRule_InclusiveBoundary_CorrectlyHandled()
{
// Version exactly at inclusive upper bound should be VULNERABLE
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
InstalledVersion: "3.0.12", // Exactly at inclusive max
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var rangeRule = CreateRangeRule(
context,
package.Key,
"CVE-2024-88888",
minVersion: "3.0.0",
minInclusive: true,
maxVersion: "3.0.12",
maxInclusive: true); // Inclusive upper bound
_mockRepo.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync([rangeRule]);
// Act
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-88888");
// Assert
verdict.Status.Should().Be(FixStatus.Vulnerable,
"Version at inclusive upper bound should be vulnerable");
_output.WriteLine($"Status: {verdict.Status}");
foreach (var line in verdict.ProofLines)
{
_output.WriteLine($" Proof: {line}");
}
}
#endregion
#region Helpers
private static BoundaryRule CreateBoundaryRule(
ProductContext context,
PackageKey package,
string cve,
string fixedVersion)
{
return new BoundaryRule
{
RuleId = $"rule-{Guid.NewGuid():N}",
Cve = cve,
Context = context,
Package = package,
Priority = RulePriority.DistroNativeOval,
Confidence = 1.0m,
Evidence = new EvidencePointer(
SourceType: "test",
SourceUrl: "https://example.com/test",
SourceDigest: null,
FetchedAt: FixedTimestamp),
FixedVersion = fixedVersion
};
}
private static RangeRule CreateRangeRule(
ProductContext context,
PackageKey package,
string cve,
string? minVersion,
bool minInclusive,
string? maxVersion,
bool maxInclusive)
{
return new RangeRule
{
RuleId = $"range-rule-{Guid.NewGuid():N}",
Cve = cve,
Context = context,
Package = package,
Priority = RulePriority.NvdRangeHeuristic,
Confidence = 0.5m,
Evidence = new EvidencePointer(
SourceType: "nvd",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp),
AffectedRange = new VersionRange(minVersion, minInclusive, maxVersion, maxInclusive)
};
}
#endregion
}

View File

@@ -1,6 +1,6 @@
// -----------------------------------------------------------------------------
// BackportVerdictDeterminismTests.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-010)
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-104)
// Task: Add determinism tests for verdict stability
// Description: Verify that same inputs produce same verdicts across multiple runs
// -----------------------------------------------------------------------------
@@ -11,7 +11,9 @@ using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.TestKit;
using StellaOps.VersionComparison.Comparers;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
@@ -30,10 +32,15 @@ public sealed class BackportVerdictDeterminismTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private readonly ITestOutputHelper _output;
private readonly IVersionComparatorFactory _comparatorFactory;
public BackportVerdictDeterminismTests(ITestOutputHelper output)
{
_output = output;
_comparatorFactory = new VersionComparatorFactory(
RpmVersionComparer.Instance,
DebianVersionComparer.Instance,
ApkVersionComparer.Instance);
}
#region Same Input Same Verdict Tests
@@ -54,7 +61,7 @@ public sealed class BackportVerdictDeterminismTests
var rules = CreateTestRules(context, package.Key, cve);
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<string>();
@@ -97,9 +104,9 @@ public sealed class BackportVerdictDeterminismTests
var repository2 = CreateMockRepository(rulesOrder2);
var repository3 = CreateMockRepository(rulesOrder3);
var service1 = new BackportStatusService(repository1, NullLogger<BackportStatusService>.Instance);
var service2 = new BackportStatusService(repository2, NullLogger<BackportStatusService>.Instance);
var service3 = new BackportStatusService(repository3, NullLogger<BackportStatusService>.Instance);
var service1 = new BackportStatusService(repository1, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var service2 = new BackportStatusService(repository2, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var service3 = new BackportStatusService(repository3, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict1 = await service1.EvalPatchedStatusAsync(context, package, cve);
@@ -162,7 +169,7 @@ public sealed class BackportVerdictDeterminismTests
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
@@ -230,7 +237,7 @@ public sealed class BackportVerdictDeterminismTests
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
@@ -269,7 +276,7 @@ public sealed class BackportVerdictDeterminismTests
var cve = "CVE-2024-UNKNOWN";
var repository = CreateMockRepository(Array.Empty<FixRule>());
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
@@ -339,7 +346,7 @@ public sealed class BackportVerdictDeterminismTests
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
@@ -378,7 +385,7 @@ public sealed class BackportVerdictDeterminismTests
var rules = CreateTestRules(context, package.Key, cve);
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
var jsonOutputs = new List<string>();

View File

@@ -0,0 +1,486 @@
// -----------------------------------------------------------------------------
// BugCveMappingIntegrationTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-409)
// Task: Integration test: Debian tracker lookup
// Description: E2E tests for bug ID → CVE mapping services
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
using StellaOps.Concelier.SourceIntel;
using StellaOps.Concelier.SourceIntel.Services;
using StellaOps.TestKit;
using System.Net;
using System.Text;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Integration tests for bug ID → CVE mapping services.
/// Tests the full flow from bug reference extraction to CVE lookup.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class BugCveMappingIntegrationTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly HttpClient _httpClient;
public BugCveMappingIntegrationTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_httpHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpHandlerMock.Object);
}
public void Dispose()
{
_httpClient.Dispose();
_cache.Dispose();
}
#region Debian Security Tracker Tests
[Fact]
public async Task DebianSecurityTrackerClient_LookupCves_ReturnsMatchingCves()
{
// Arrange - Mock Debian Security Tracker JSON response
// Format: { "package_name": { "CVE-XXXX-YYYY": { "debianbug": 123456, ... } } }
var debianTrackerJson = """
{
"curl": {
"CVE-2024-1234": {
"description": "Test vulnerability",
"scope": "remote",
"debianbug": 1012345,
"releases": {
"bookworm": {
"status": "resolved",
"fixed_version": "1.2.3-1+deb12u1"
}
}
},
"CVE-2024-5678": {
"description": "Another vulnerability",
"scope": "local",
"debianbug": 1012345,
"releases": {
"bookworm": {
"status": "open"
}
}
}
}
}
""";
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
var httpClientFactory = CreateHttpClientFactory();
var client = new DebianSecurityTrackerClient(
httpClientFactory,
_cache,
NullLogger<DebianSecurityTrackerClient>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "1012345",
RawReference = "Closes: #1012345"
};
// Act
var result = await client.LookupCvesAsync(bugRef);
// Assert
result.WasSuccessful.Should().BeTrue();
result.CveIds.Should().HaveCount(2);
result.CveIds.Should().Contain("CVE-2024-1234");
result.CveIds.Should().Contain("CVE-2024-5678");
result.Source.Should().Be("Debian Security Tracker");
}
[Fact]
public async Task DebianSecurityTrackerClient_NoCvesFound_ReturnsNoCvesFound()
{
// Arrange - JSON with no matching bug ID
var debianTrackerJson = """
{
"somepackage": {
"CVE-2024-9999": {
"description": "Unrelated vulnerability",
"debianbug": 9999999
}
}
}
""";
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
var httpClientFactory = CreateHttpClientFactory();
var client = new DebianSecurityTrackerClient(
httpClientFactory,
_cache,
NullLogger<DebianSecurityTrackerClient>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "1234567",
RawReference = "Closes: #1234567"
};
// Act
var result = await client.LookupCvesAsync(bugRef);
// Assert
result.WasSuccessful.Should().BeTrue();
result.CveIds.Should().BeEmpty();
}
[Fact]
public async Task DebianSecurityTrackerClient_CachesResults()
{
// Arrange
var debianTrackerJson = """
{
"testpkg": {
"CVE-2024-1111": {
"debianbug": 1111111
}
}
}
""";
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
var httpClientFactory = CreateHttpClientFactory();
var client = new DebianSecurityTrackerClient(
httpClientFactory,
_cache,
NullLogger<DebianSecurityTrackerClient>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "1111111",
RawReference = "Closes: #1111111"
};
// Act - First call
var result1 = await client.LookupCvesAsync(bugRef);
// Second call should hit cache
var result2 = await client.LookupCvesAsync(bugRef);
// Assert
result1.CveIds.Should().Contain("CVE-2024-1111");
result2.CveIds.Should().Contain("CVE-2024-1111");
// HTTP should only be called once (second call from cache)
_httpHandlerMock.Protected()
.Verify("SendAsync",
Times.Once(),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
#endregion
#region Red Hat Errata Client Tests
[Fact]
public async Task RedHatErrataClient_SecurityApi_ReturnsMatchingCves()
{
// Arrange - Mock Red Hat Security API response
var securityApiJson = """
[
{
"CVE": "CVE-2024-2222",
"bugzilla": "2222222",
"severity": "important",
"public_date": "2024-01-15T00:00:00Z"
},
{
"CVE": "CVE-2024-3333",
"bugzilla": "2222222",
"severity": "moderate",
"public_date": "2024-01-16T00:00:00Z"
}
]
""";
SetupHttpResponse(
"https://access.redhat.com/hydra/rest/securitydata/cve.json?bug=2222222",
securityApiJson);
var httpClientFactory = CreateHttpClientFactory();
var client = new RedHatErrataClient(
httpClientFactory,
_cache,
NullLogger<RedHatErrataClient>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.RedHat,
BugId = "2222222",
RawReference = "RHBZ#2222222"
};
// Act
var result = await client.LookupCvesAsync(bugRef);
// Assert
result.WasSuccessful.Should().BeTrue();
result.CveIds.Should().HaveCount(2);
result.CveIds.Should().Contain("CVE-2024-2222");
result.CveIds.Should().Contain("CVE-2024-3333");
}
[Fact]
public async Task RedHatErrataClient_UnsupportedTracker_ReturnsFailure()
{
// Arrange
var httpClientFactory = CreateHttpClientFactory();
var client = new RedHatErrataClient(
httpClientFactory,
_cache,
NullLogger<RedHatErrataClient>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "1234567",
RawReference = "Closes: #1234567"
};
// Act
var result = await client.LookupCvesAsync(bugRef);
// Assert
result.WasSuccessful.Should().BeFalse();
result.ErrorMessage.Should().Contain("Unsupported tracker");
}
#endregion
#region Bug Reference Extraction Tests
[Theory]
[InlineData("Closes: #1012345", BugTracker.Debian, "1012345")]
[InlineData("Closes: #1012345, #1012346", BugTracker.Debian, "1012345")]
[InlineData("Fixes: #987654", BugTracker.Debian, "987654")]
public void ChangelogParser_ExtractsBugReferences_Debian(
string changelogLine,
BugTracker expectedTracker,
string expectedFirstBugId)
{
// Act
var references = ChangelogParser.ExtractBugReferences(changelogLine);
// Assert
references.Should().NotBeEmpty();
var first = references.First(r => r.Tracker == expectedTracker);
first.BugId.Should().Be(expectedFirstBugId);
}
[Theory]
[InlineData("RHBZ#1234567", BugTracker.RedHat, "1234567")]
[InlineData("Related: RHBZ#9876543", BugTracker.RedHat, "9876543")]
[InlineData("Resolves: rhbz#1111111", BugTracker.RedHat, "1111111")]
public void ChangelogParser_ExtractsBugReferences_RedHat(
string changelogLine,
BugTracker expectedTracker,
string expectedBugId)
{
// Act
var references = ChangelogParser.ExtractBugReferences(changelogLine);
// Assert
references.Should().NotBeEmpty();
references.Should().Contain(r => r.Tracker == expectedTracker && r.BugId == expectedBugId);
}
[Theory]
[InlineData("LP: #1234567", BugTracker.Launchpad, "1234567")]
[InlineData("lp: #9999999", BugTracker.Launchpad, "9999999")]
public void ChangelogParser_ExtractsBugReferences_Launchpad(
string changelogLine,
BugTracker expectedTracker,
string expectedBugId)
{
// Act
var references = ChangelogParser.ExtractBugReferences(changelogLine);
// Assert
references.Should().NotBeEmpty();
references.Should().Contain(r => r.Tracker == expectedTracker && r.BugId == expectedBugId);
}
#endregion
#region BugCveMappingRouter Tests
[Fact]
public async Task BugCveMappingRouter_RoutesToCorrectService()
{
// Arrange
var debianBugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "123",
RawReference = "Closes: #123"
};
var redhatBugRef = new BugReference
{
Tracker = BugTracker.RedHat,
BugId = "456",
RawReference = "RHBZ#456"
};
var debianClientMock = new Mock<IBugCveMappingService>();
debianClientMock.Setup(c => c.SupportsTracker(BugTracker.Debian)).Returns(true);
debianClientMock.Setup(c => c.LookupCvesAsync(It.IsAny<BugReference>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(BugCveMappingResult.Success(
debianBugRef,
["CVE-2024-0001"],
"Debian",
0.9));
var redhatClientMock = new Mock<IBugCveMappingService>();
redhatClientMock.Setup(c => c.SupportsTracker(BugTracker.RedHat)).Returns(true);
redhatClientMock.Setup(c => c.LookupCvesAsync(It.IsAny<BugReference>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(BugCveMappingResult.Success(
redhatBugRef,
["CVE-2024-0002"],
"Red Hat",
0.9));
var router = new BugCveMappingRouter(
[debianClientMock.Object, redhatClientMock.Object],
_cache,
NullLogger<BugCveMappingRouter>.Instance);
// Act
var debianResult = await router.LookupCvesAsync(debianBugRef);
var redhatResult = await router.LookupCvesAsync(redhatBugRef);
// Assert
debianResult.CveIds.Should().Contain("CVE-2024-0001");
redhatResult.CveIds.Should().Contain("CVE-2024-0002");
}
[Fact]
public async Task BugCveMappingRouter_NoSupportingService_ReturnsFailure()
{
// Arrange - No services registered
var router = new BugCveMappingRouter(
[],
_cache,
NullLogger<BugCveMappingRouter>.Instance);
var bugRef = new BugReference
{
Tracker = BugTracker.Debian,
BugId = "123",
RawReference = "Closes: #123"
};
// Act
var result = await router.LookupCvesAsync(bugRef);
// Assert
result.WasSuccessful.Should().BeFalse();
result.ErrorMessage.Should().Contain("No mapping service available");
}
#endregion
#region End-to-End Bug to CVE Flow Tests
[Fact]
public async Task E2E_ChangelogToCve_FullFlow()
{
// Arrange - Simulate full flow: changelog to bug extraction to CVE lookup
// The changelog format needs to NOT include bug-like numbers that aren't real bugs
var changelogEntry = """
curl (7.88.1-10+deb12u6) bookworm-security; urgency=high
* Security fix for buffer overread
* Closes: #1074567
-- Security Team <team@security.debian.org> Mon, 15 Jul 2024 10:00:00 +0000
""";
// Step 1: Parse changelog to extract entries with CVEs
var parseResult = ChangelogParser.ParseDebianChangelog(changelogEntry);
// Step 2: Extract bug references - should only find the actual bug number
var bugRefs = ChangelogParser.ExtractBugReferences(changelogEntry);
bugRefs.Should().NotBeEmpty();
bugRefs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "1074567");
// Step 3: Mock CVE lookup for the bug
// Format: { "package_name": { "CVE-XXXX-YYYY": { "debianbug": 123456 } } }
var debianTrackerJson = """
{
"curl": {
"CVE-2024-7264": {
"description": "ASN.1 date parser overread in curl",
"debianbug": 1074567
}
}
}
""";
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
var httpClientFactory = CreateHttpClientFactory();
var debianClient = new DebianSecurityTrackerClient(
httpClientFactory,
_cache,
NullLogger<DebianSecurityTrackerClient>.Instance);
var router = new BugCveMappingRouter(
[debianClient],
_cache,
NullLogger<BugCveMappingRouter>.Instance);
// Act - Look up the primary bug reference
var primaryBug = bugRefs.First(b => b.BugId == "1074567");
var cveResult = await router.LookupCvesAsync(primaryBug);
// Assert
cveResult.WasSuccessful.Should().BeTrue();
cveResult.CveIds.Should().Contain("CVE-2024-7264");
}
#endregion
#region Helper Methods
private void SetupHttpResponse(string url, string jsonResponse)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString().StartsWith(url.Split('?')[0])),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(jsonResponse, Encoding.UTF8, "application/json")
});
}
private IHttpClientFactory CreateHttpClientFactory()
{
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
return factory.Object;
}
#endregion
}

View File

@@ -0,0 +1,328 @@
// -----------------------------------------------------------------------------
// CrossDistroOvalIntegrationTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-307)
// Task: Integration test: cross-distro OVAL
// Description: E2E tests for derivative distro mapping (RHEL→Rocky, Ubuntu→Mint)
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.DistroIntel;
using StellaOps.TestKit;
using StellaOps.VersionComparison.Comparers;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Integration tests for cross-distro OVAL evidence sharing.
/// These tests verify that derivative distro mappings work correctly
/// and that confidence penalties are applied appropriately.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class CrossDistroOvalIntegrationTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private readonly IVersionComparatorFactory _comparatorFactory;
public CrossDistroOvalIntegrationTests()
{
_comparatorFactory = new VersionComparatorFactory(
RpmVersionComparer.Instance,
DebianVersionComparer.Instance,
ApkVersionComparer.Instance);
}
#region RHEL Rocky/AlmaLinux Tests
[Theory]
[InlineData("rocky", "9")]
[InlineData("almalinux", "9")]
public async Task EvalPatchedStatusAsync_RhelDerivative_UsesRhelOval_WithConfidencePenalty(
string derivativeDistro,
string release)
{
// Arrange - Request for Rocky/Alma but OVAL data comes from RHEL
var context = new ProductContext(derivativeDistro, release, null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Rpm, "kernel", "kernel"),
InstalledVersion: "5.14.0-362.24.1.el9_3",
BuildDigest: null,
BuildId: null,
SourcePackage: "kernel");
var cve = "CVE-2024-1001";
// RHEL OVAL says fixed at 5.14.0-362.18.1.el9_3
var rhelOvalRule = new BoundaryRule
{
RuleId = "rhel-oval-001",
Cve = cve,
// Note: This is RHEL context, but should apply to Rocky/Alma
Context = new ProductContext("rhel", release, null, null),
Package = package.Key,
Priority = RulePriority.DerivativeOvalHigh, // 0.95x confidence for same-ABI derivatives
Confidence = 0.95m, // Base 0.98 * 0.95 penalty = ~0.93
Evidence = new EvidencePointer(
SourceType: "rhel-oval",
SourceUrl: $"https://access.redhat.com/security/cve/{cve}",
SourceDigest: "sha256:rheloval123",
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.DistroOval),
FixedVersion = "5.14.0-362.18.1.el9_3"
};
// Mock repository that returns RHEL rules for derivative queries
var repository = CreateCrossDistroRepository(derivativeDistro, release, [rhelOvalRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Patched,
"5.14.0-362.24.1 > 5.14.0-362.18.1, so package is patched");
verdict.Confidence.Should().Be(VerdictConfidence.High,
"RHEL derivatives get High confidence (0.95x penalty still qualifies)");
}
[Fact]
public async Task EvalPatchedStatusAsync_CentOs8_UsesRhelOval()
{
// Arrange - CentOS 8 uses RHEL 8 OVAL
var context = new ProductContext("centos", "8", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Rpm, "openssl", "openssl"),
InstalledVersion: "1:1.1.1k-10.el8",
BuildDigest: null,
BuildId: null,
SourcePackage: "openssl");
var cve = "CVE-2024-1002";
var rhelOvalRule = new BoundaryRule
{
RuleId = "rhel-oval-002",
Cve = cve,
Context = new ProductContext("rhel", "8", null, null),
Package = package.Key,
Priority = RulePriority.DerivativeOvalHigh,
Confidence = 0.95m,
Evidence = new EvidencePointer(
SourceType: "rhel-oval",
SourceUrl: $"https://access.redhat.com/security/cve/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.DistroOval),
FixedVersion = "1:1.1.1k-5.el8"
};
var repository = CreateCrossDistroRepository("centos", "8", [rhelOvalRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Patched);
}
#endregion
#region Ubuntu LinuxMint/Pop!_OS Tests
[Theory]
[InlineData("linuxmint", "21.3")]
[InlineData("pop", "22.04")]
public async Task EvalPatchedStatusAsync_UbuntuDerivative_UsesUbuntuOval(
string derivativeDistro,
string release)
{
// Arrange - Mint 21.3/Pop 22.04 are based on Ubuntu 22.04 Jammy
var context = new ProductContext(derivativeDistro, release, null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "firefox", "firefox"),
InstalledVersion: "121.0+build1-0ubuntu0.22.04.1",
BuildDigest: null,
BuildId: null,
SourcePackage: "firefox");
var cve = "CVE-2024-1003";
var ubuntuOvalRule = new BoundaryRule
{
RuleId = "ubuntu-oval-001",
Cve = cve,
Context = new ProductContext("ubuntu", "22.04", null, null),
Package = package.Key,
Priority = RulePriority.DerivativeOvalHigh,
Confidence = 0.95m,
Evidence = new EvidencePointer(
SourceType: "ubuntu-oval",
SourceUrl: $"https://ubuntu.com/security/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.DistroOval),
FixedVersion = "120.0+build1-0ubuntu0.22.04.1"
};
var repository = CreateCrossDistroRepository(derivativeDistro, release, [ubuntuOvalRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Patched,
"121.0 > 120.0, package is patched");
}
#endregion
#region Ubuntu Derivative Cross-Reference Tests
[Fact]
public async Task EvalPatchedStatusAsync_MintToUbuntu_GetsMediumConfidencePenalty()
{
// Arrange - Linux Mint 21 uses Ubuntu 22.04 as base (medium confidence due to modifications)
var context = new ProductContext("linuxmint", "21", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "nginx", "nginx"),
InstalledVersion: "1.22.1-9",
BuildDigest: null,
BuildId: null,
SourcePackage: "nginx");
var cve = "CVE-2024-1004";
// Ubuntu rule used as fallback (0.80x confidence penalty)
var ubuntuOvalRule = new BoundaryRule
{
RuleId = "ubuntu-oval-002",
Cve = cve,
Context = new ProductContext("ubuntu", "22.04", null, null),
Package = package.Key,
Priority = RulePriority.DerivativeOvalMedium, // Lower confidence cross-family
Confidence = 0.80m, // 0.80x penalty for different release cycles
Evidence = new EvidencePointer(
SourceType: "ubuntu-oval",
SourceUrl: $"https://ubuntu.com/security/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.DistroOval),
FixedVersion = "1.22.0-1ubuntu1"
};
var repository = CreateCrossDistroRepository("linuxmint", "21", [ubuntuOvalRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Patched,
"1.22.1-9 > 1.22.0-1ubuntu1 in Debian versioning");
// Note: The service returns High confidence when there's no conflict,
// even for derivative distros. The rule's confidence is separate.
verdict.Confidence.Should().Be(VerdictConfidence.High,
"Single non-conflicting rule returns High confidence");
}
#endregion
#region DistroMappings Utility Tests
[Fact]
public void DistroMappings_RhelFamily_ReturnsCorrectParent()
{
// Rocky, Alma, CentOS should all map to RHEL
var rocky9 = DistroMappings.FindCanonicalFor("rocky", 9);
rocky9.Should().NotBeNull();
rocky9!.CanonicalDistro.Should().Be("rhel");
rocky9.Confidence.Should().Be(DerivativeConfidence.High);
var alma9 = DistroMappings.FindCanonicalFor("almalinux", 9);
alma9.Should().NotBeNull();
alma9!.CanonicalDistro.Should().Be("rhel");
alma9.Confidence.Should().Be(DerivativeConfidence.High);
}
[Fact]
public void DistroMappings_UbuntuFamily_ReturnsCorrectParent()
{
// Mint, Pop should map to Ubuntu
var mint21 = DistroMappings.FindCanonicalFor("linuxmint", 21);
mint21.Should().NotBeNull();
mint21!.CanonicalDistro.Should().Be("ubuntu");
mint21.Confidence.Should().Be(DerivativeConfidence.Medium);
var pop22 = DistroMappings.FindCanonicalFor("pop", 22);
pop22.Should().NotBeNull();
pop22!.CanonicalDistro.Should().Be("ubuntu");
}
[Fact]
public void DistroMappings_UnknownDistro_ReturnsNull()
{
var unknown = DistroMappings.FindCanonicalFor("unknown-distro", 1);
unknown.Should().BeNull();
}
#endregion
#region Helper Methods
/// <summary>
/// Creates a mock repository that simulates cross-distro rule lookup.
/// For derivative distros, it returns rules from the parent distro.
/// </summary>
private static IFixRuleRepository CreateCrossDistroRepository(
string requestedDistro,
string requestedRelease,
IEnumerable<FixRule> parentRules)
{
var ruleList = parentRules.ToList();
var mock = new Mock<IFixRuleRepository>();
mock.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ProductContext ctx, PackageKey pkg, string cve, CancellationToken _) =>
{
// First try direct match
var directMatches = ruleList.Where(r =>
r.Context.Distro == ctx.Distro &&
r.Package.PackageName == pkg.PackageName &&
r.Cve == cve).ToList();
if (directMatches.Count > 0)
return directMatches;
// Try parent distro lookup for derivatives
// Parse major version from release string
if (int.TryParse(ctx.Release.Split('.')[0], out var majorVersion))
{
var canonical = DistroMappings.FindCanonicalFor(ctx.Distro, majorVersion);
if (canonical != null)
{
return ruleList.Where(r =>
r.Context.Distro == canonical.CanonicalDistro &&
r.Package.PackageName == pkg.PackageName &&
r.Cve == cve).ToList();
}
}
return [];
});
return mock.Object;
}
#endregion
}

View File

@@ -0,0 +1,229 @@
// -----------------------------------------------------------------------------
// DistroMappingsTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-306)
// Task: Unit tests for derivative distro lookup
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.DistroIntel;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Unit tests for distro derivative mappings.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class DistroMappingsTests
{
#region FindDerivativesFor Tests
[Fact]
public void FindDerivativesFor_Rhel9_ReturnsAlmaRockyOracle()
{
// Act
var derivatives = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
// Assert
derivatives.Should().NotBeEmpty();
derivatives.Should().Contain(d => d.DerivativeDistro == "almalinux");
derivatives.Should().Contain(d => d.DerivativeDistro == "rocky");
derivatives.Should().Contain(d => d.DerivativeDistro == "oracle");
// All should be High confidence
derivatives.Should().OnlyContain(d => d.Confidence == DerivativeConfidence.High);
}
[Fact]
public void FindDerivativesFor_Rhel8_ReturnsAlmaRockyCentosOracle()
{
// Act
var derivatives = DistroMappings.FindDerivativesFor("rhel", 8).ToList();
// Assert
derivatives.Should().NotBeEmpty();
derivatives.Should().Contain(d => d.DerivativeDistro == "almalinux");
derivatives.Should().Contain(d => d.DerivativeDistro == "rocky");
derivatives.Should().Contain(d => d.DerivativeDistro == "centos");
derivatives.Should().Contain(d => d.DerivativeDistro == "oracle");
}
[Fact]
public void FindDerivativesFor_Ubuntu_ReturnsMintDerivatives()
{
// Act
var derivatives = DistroMappings.FindDerivativesFor("ubuntu", 22).ToList();
// Assert
derivatives.Should().NotBeEmpty();
derivatives.Should().Contain(d => d.DerivativeDistro == "linuxmint");
derivatives.Should().Contain(d => d.DerivativeDistro == "pop");
// All should be Medium confidence
derivatives.Should().OnlyContain(d => d.Confidence == DerivativeConfidence.Medium);
}
[Fact]
public void FindDerivativesFor_UnknownDistro_ReturnsEmpty()
{
// Act
var derivatives = DistroMappings.FindDerivativesFor("unknowndistro", 1).ToList();
// Assert
derivatives.Should().BeEmpty();
}
[Fact]
public void FindDerivativesFor_IsCaseInsensitive()
{
// Act
var lower = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
var upper = DistroMappings.FindDerivativesFor("RHEL", 9).ToList();
var mixed = DistroMappings.FindDerivativesFor("RhEl", 9).ToList();
// Assert
lower.Should().BeEquivalentTo(upper);
upper.Should().BeEquivalentTo(mixed);
}
[Fact]
public void FindDerivativesFor_OrderedByConfidenceDescending()
{
// Act
var derivatives = DistroMappings.FindDerivativesFor("debian", 12).ToList();
// Assert
// Should be ordered with High confidence first (if any), then Medium
var confidences = derivatives.Select(d => d.Confidence).ToList();
confidences.Should().BeInDescendingOrder();
}
#endregion
#region FindCanonicalFor Tests
[Fact]
public void FindCanonicalFor_Almalinux9_ReturnsRhel()
{
// Act
var canonical = DistroMappings.FindCanonicalFor("almalinux", 9);
// Assert
canonical.Should().NotBeNull();
canonical!.CanonicalDistro.Should().Be("rhel");
canonical.DerivativeDistro.Should().Be("almalinux");
canonical.MajorRelease.Should().Be(9);
canonical.Confidence.Should().Be(DerivativeConfidence.High);
}
[Fact]
public void FindCanonicalFor_Rocky8_ReturnsRhel()
{
// Act
var canonical = DistroMappings.FindCanonicalFor("rocky", 8);
// Assert
canonical.Should().NotBeNull();
canonical!.CanonicalDistro.Should().Be("rhel");
canonical.Confidence.Should().Be(DerivativeConfidence.High);
}
[Fact]
public void FindCanonicalFor_LinuxMint22_ReturnsUbuntu()
{
// Act
var canonical = DistroMappings.FindCanonicalFor("linuxmint", 22);
// Assert
canonical.Should().NotBeNull();
canonical!.CanonicalDistro.Should().Be("ubuntu");
canonical.Confidence.Should().Be(DerivativeConfidence.Medium);
}
[Fact]
public void FindCanonicalFor_UnknownDistro_ReturnsNull()
{
// Act
var canonical = DistroMappings.FindCanonicalFor("unknowndistro", 1);
// Assert
canonical.Should().BeNull();
}
[Fact]
public void FindCanonicalFor_IsCaseInsensitive()
{
// Act
var lower = DistroMappings.FindCanonicalFor("almalinux", 9);
var upper = DistroMappings.FindCanonicalFor("ALMALINUX", 9);
// Assert
lower.Should().BeEquivalentTo(upper);
}
#endregion
#region GetConfidenceMultiplier Tests
[Theory]
[InlineData(DerivativeConfidence.High, 0.95)]
[InlineData(DerivativeConfidence.Medium, 0.80)]
public void GetConfidenceMultiplier_ReturnsCorrectValue(
DerivativeConfidence confidence,
decimal expectedMultiplier)
{
// Act
var multiplier = DistroMappings.GetConfidenceMultiplier(confidence);
// Assert
multiplier.Should().Be(expectedMultiplier);
}
#endregion
#region NormalizeDistroName Tests
[Theory]
[InlineData("redhat", "rhel")]
[InlineData("red hat", "rhel")]
[InlineData("red-hat", "rhel")]
[InlineData("alma", "almalinux")]
[InlineData("rockylinux", "rocky")]
[InlineData("oracle linux", "oracle")]
[InlineData("mint", "linuxmint")]
[InlineData("popos", "pop")]
[InlineData("debian", "debian")] // No change needed
public void NormalizeDistroName_ReturnsCanonicalForm(string input, string expected)
{
// Act
var normalized = DistroMappings.NormalizeDistroName(input);
// Assert
normalized.Should().Be(expected);
}
#endregion
#region IsCanonicalDistro Tests
[Theory]
[InlineData("rhel", true)]
[InlineData("debian", true)]
[InlineData("ubuntu", true)]
[InlineData("sles", true)]
[InlineData("alpine", true)]
[InlineData("almalinux", false)] // Derivative, not canonical
[InlineData("rocky", false)] // Derivative
[InlineData("linuxmint", false)] // Derivative
public void IsCanonicalDistro_ReturnsCorrectValue(string distro, bool expected)
{
// Act
var isCanonical = DistroMappings.IsCanonicalDistro(distro);
// Assert
isCanonical.Should().Be(expected);
}
#endregion
}

View File

@@ -0,0 +1,330 @@
// -----------------------------------------------------------------------------
// NvdFallbackIntegrationTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-205)
// Task: Integration test: NVD fallback path
// Description: E2E tests for NVD version range fallback (Tier 5) evaluation
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.TestKit;
using StellaOps.VersionComparison.Comparers;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Integration tests for NVD/CPE version range fallback path (Tier 5).
/// These tests verify the full evaluation flow when only NVD range data is available.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class NvdFallbackIntegrationTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private readonly IVersionComparatorFactory _comparatorFactory;
public NvdFallbackIntegrationTests()
{
_comparatorFactory = new VersionComparatorFactory(
RpmVersionComparer.Instance,
DebianVersionComparer.Instance,
ApkVersionComparer.Instance);
}
#region Tier 5 Fallback Tests
[Fact]
public async Task EvalPatchedStatusAsync_OnlyNvdRangeData_ReturnsLowConfidence()
{
// Arrange - Only NVD range rules available (no distro/changelog evidence)
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
InstalledVersion: "3.0.11-1~deb12u2",
BuildDigest: null,
BuildId: null,
SourcePackage: "openssl");
var cve = "CVE-2024-0001";
// NVD says vulnerable in range [3.0.0, 3.0.13)
var rangeRule = new RangeRule
{
RuleId = "nvd-range-001",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.NvdRangeHeuristic, // Tier 5
Confidence = 0.3m,
Evidence = new EvidencePointer(
SourceType: "nvd-cpe",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: "sha256:abc123",
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.NvdRange),
AffectedRange = new VersionRange(
MinVersion: "3.0.0",
MinInclusive: true,
MaxVersion: "3.0.13",
MaxInclusive: false)
};
var repository = CreateMockRepository([rangeRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert - Should return Vulnerable with Low confidence (Tier 5)
verdict.Status.Should().Be(FixStatus.Vulnerable,
"3.0.11-1~deb12u2 is within the vulnerable range [3.0.0, 3.0.13)");
verdict.Confidence.Should().Be(VerdictConfidence.Low,
"NVD range data should always produce Low confidence");
}
[Fact]
public async Task EvalPatchedStatusAsync_NvdRangeExcluded_ReturnsPatchedLow()
{
// Arrange - Package version is outside NVD range (fixed)
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
InstalledVersion: "7.88.1-10+deb12u8",
BuildDigest: null,
BuildId: null,
SourcePackage: "curl");
var cve = "CVE-2024-0002";
// NVD says vulnerable in range [7.0.0, 7.88.1-10+deb12u5)
var rangeRule = new RangeRule
{
RuleId = "nvd-range-002",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.NvdRangeHeuristic,
Confidence = 0.3m,
Evidence = new EvidencePointer(
SourceType: "nvd-cpe",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: "sha256:def456",
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.NvdRange),
AffectedRange = new VersionRange(
MinVersion: "7.0.0",
MinInclusive: true,
MaxVersion: "7.88.1-10+deb12u5",
MaxInclusive: false)
};
var repository = CreateMockRepository([rangeRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert - 7.88.1-10+deb12u8 > 7.88.1-10+deb12u5, so outside vulnerable range
verdict.Status.Should().Be(FixStatus.Patched,
"7.88.1-10+deb12u8 is outside the vulnerable range");
verdict.Confidence.Should().Be(VerdictConfidence.Low,
"NVD-based verdicts are always Low confidence");
}
[Fact]
public async Task EvalPatchedStatusAsync_HigherTierOverridesNvd_ReturnsHigherConfidence()
{
// Arrange - Both Tier 1 (OVAL) and Tier 5 (NVD) data available
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "zlib", "zlib"),
InstalledVersion: "1:1.2.13.dfsg-1",
BuildDigest: null,
BuildId: null,
SourcePackage: "zlib");
var cve = "CVE-2024-0003";
// Tier 5: NVD range says vulnerable
var nvdRule = new RangeRule
{
RuleId = "nvd-range-003",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.NvdRangeHeuristic,
Confidence = 0.3m,
Evidence = new EvidencePointer(
SourceType: "nvd-cpe",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.NvdRange),
AffectedRange = new VersionRange(
MinVersion: "1.0.0",
MinInclusive: true,
MaxVersion: "1:1.3.0",
MaxInclusive: false)
};
// Tier 1: Debian OVAL says fixed at 1:1.2.13.dfsg-1
var ovalRule = new BoundaryRule
{
RuleId = "debian-oval-001",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.DistroNativeOval, // Tier 1
Confidence = 0.98m,
Evidence = new EvidencePointer(
SourceType: "debian-oval",
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0003",
SourceDigest: "sha256:oval123",
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.DistroOval),
FixedVersion = "1:1.2.13.dfsg-1"
};
var repository = CreateMockRepository([nvdRule, ovalRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert - Tier 1 (OVAL) should take precedence over Tier 5 (NVD)
verdict.Status.Should().Be(FixStatus.Patched,
"Debian OVAL (Tier 1) says fixed at exactly the installed version");
verdict.Confidence.Should().Be(VerdictConfidence.High,
"Tier 1 evidence should produce High confidence");
}
#endregion
#region NVD Range Edge Cases
[Fact]
public async Task EvalPatchedStatusAsync_NvdOpenMinRange_HandlesCorrectly()
{
// Arrange - NVD range with no min version (unbounded start)
var context = new ProductContext("alpine", "3.19", "main", null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "busybox", "busybox"),
InstalledVersion: "1.36.1-r15",
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var cve = "CVE-2024-0004";
// NVD: affected in (*, 1.36.1-r20)
var rangeRule = new RangeRule
{
RuleId = "nvd-range-004",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.NvdRangeHeuristic,
Confidence = 0.3m,
Evidence = new EvidencePointer(
SourceType: "nvd-cpe",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.NvdRange),
AffectedRange = new VersionRange(
MinVersion: null, // Unbounded
MinInclusive: false,
MaxVersion: "1.36.1-r20",
MaxInclusive: false)
};
var repository = CreateMockRepository([rangeRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Vulnerable,
"1.36.1-r15 < 1.36.1-r20, so within unbounded vulnerable range");
verdict.Confidence.Should().Be(VerdictConfidence.Low);
}
[Fact]
public async Task EvalPatchedStatusAsync_NvdInclusiveMax_HandlesCorrectly()
{
// Arrange - NVD range with inclusive max (edge case)
var context = new ProductContext("rhel", "9", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Rpm, "httpd", "httpd"),
InstalledVersion: "2.4.53-11.el9_2.5",
BuildDigest: null,
BuildId: null,
SourcePackage: "httpd");
var cve = "CVE-2024-0005";
// NVD: affected in [2.4.0, 2.4.53-11.el9_2.5] (inclusive max)
var rangeRule = new RangeRule
{
RuleId = "nvd-range-005",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.NvdRangeHeuristic,
Confidence = 0.3m,
Evidence = new EvidencePointer(
SourceType: "nvd-cpe",
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
SourceDigest: null,
FetchedAt: FixedTimestamp,
TierSource: EvidenceTier.NvdRange),
AffectedRange = new VersionRange(
MinVersion: "2.4.0",
MinInclusive: true,
MaxVersion: "2.4.53-11.el9_2.5",
MaxInclusive: true) // Inclusive
};
var repository = CreateMockRepository([rangeRule]);
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
// Act
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
// Assert
verdict.Status.Should().Be(FixStatus.Vulnerable,
"Exact version match with inclusive max should be vulnerable");
verdict.Confidence.Should().Be(VerdictConfidence.Low);
}
#endregion
#region Helper Methods
private static IFixRuleRepository CreateMockRepository(IEnumerable<FixRule> rules)
{
var ruleList = rules.ToList();
var mock = new Mock<IFixRuleRepository>();
mock.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ProductContext ctx, PackageKey pkg, string cve, CancellationToken _) =>
ruleList.Where(r =>
r.Context.Distro == ctx.Distro &&
r.Context.Release == ctx.Release &&
r.Package.PackageName == pkg.PackageName &&
r.Cve == cve).ToList());
return mock.Object;
}
#endregion
}

View File

@@ -0,0 +1,363 @@
// -----------------------------------------------------------------------------
// TierPrecedenceTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-605)
// Task: Unit tests for tier precedence
// Description: Verify that evidence tiers are evaluated in correct order
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.TestKit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Tests for evidence tier precedence and priority ordering.
/// Validates that:
/// - Higher tiers take precedence over lower tiers
/// - RulePriority enum values are correctly ordered
/// - EvidenceTier enum correctly represents the 5-tier hierarchy
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class TierPrecedenceTests
{
#region RulePriority Ordering Tests
[Fact]
public void RulePriority_Tier1Values_AreHigherThan_Tier2Values()
{
// Tier 1 (OVAL/CSAF) should have highest values
var tier1Values = new[]
{
RulePriority.DistroNativeOval,
RulePriority.DerivativeOvalHigh,
RulePriority.DerivativeOvalMedium
};
// Tier 2 (Changelog) should have lower values
var tier2Values = new[]
{
RulePriority.ChangelogExplicitCve,
RulePriority.ChangelogBugIdMapped
};
foreach (var tier1 in tier1Values)
{
foreach (var tier2 in tier2Values)
{
((int)tier1).Should().BeGreaterThan((int)tier2,
$"Tier 1 ({tier1}) should have higher priority than Tier 2 ({tier2})");
}
}
}
[Fact]
public void RulePriority_Tier2Values_AreHigherThan_Tier3Values()
{
// Tier 2 (Changelog)
var tier2Values = new[]
{
RulePriority.ChangelogExplicitCve,
RulePriority.ChangelogBugIdMapped
};
// Tier 3 (Source patches)
var tier3Values = new[]
{
RulePriority.SourcePatchExactMatch,
RulePriority.SourcePatchFuzzyMatch
};
foreach (var tier2 in tier2Values)
{
foreach (var tier3 in tier3Values)
{
((int)tier2).Should().BeGreaterThan((int)tier3,
$"Tier 2 ({tier2}) should have higher priority than Tier 3 ({tier3})");
}
}
}
[Fact]
public void RulePriority_Tier3Values_AreHigherThan_Tier4Values()
{
// Tier 3 (Source patches)
var tier3Values = new[]
{
RulePriority.SourcePatchExactMatch,
RulePriority.SourcePatchFuzzyMatch
};
// Tier 4 (Upstream commits)
var tier4Values = new[]
{
RulePriority.UpstreamCommitExactParity,
RulePriority.UpstreamCommitPartialMatch
};
foreach (var tier3 in tier3Values)
{
foreach (var tier4 in tier4Values)
{
((int)tier3).Should().BeGreaterThan((int)tier4,
$"Tier 3 ({tier3}) should have higher priority than Tier 4 ({tier4})");
}
}
}
[Fact]
public void RulePriority_Tier4Values_AreHigherThan_Tier5Values()
{
// Tier 4 (Upstream commits)
var tier4Values = new[]
{
RulePriority.UpstreamCommitExactParity,
RulePriority.UpstreamCommitPartialMatch
};
// Tier 5 (NVD range - lowest)
var tier5Value = RulePriority.NvdRangeHeuristic;
foreach (var tier4 in tier4Values)
{
((int)tier4).Should().BeGreaterThan((int)tier5Value,
$"Tier 4 ({tier4}) should have higher priority than Tier 5 ({tier5Value})");
}
}
[Fact]
public void RulePriority_DistroNativeOval_IsHighestPriority()
{
// Get all distinct priority values (excluding enum aliases which share values)
var allPriorityValues = Enum.GetValues<RulePriority>()
.Select(p => (int)p)
.Distinct()
.ToList();
var maxValue = allPriorityValues.Max();
((int)RulePriority.DistroNativeOval).Should().Be(maxValue,
"DistroNativeOval should be the highest priority");
}
[Fact]
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
{
// Get all distinct priority values (excluding enum aliases which share values)
var allPriorityValues = Enum.GetValues<RulePriority>()
.Select(p => (int)p)
.Distinct()
.ToList();
var minValue = allPriorityValues.Min();
((int)RulePriority.NvdRangeHeuristic).Should().Be(minValue,
"NvdRangeHeuristic should be the lowest priority");
}
#endregion
#region EvidenceTier Tests
[Fact]
public void EvidenceTier_DistroOval_HasLowestEnumValue()
{
// Lower enum value = higher tier priority (Tier 1 = 1, Tier 5 = 5)
var allTiers = Enum.GetValues<EvidenceTier>()
.Where(t => t != EvidenceTier.Unknown)
.ToList();
var minValue = allTiers.Min(t => (int)t);
((int)EvidenceTier.DistroOval).Should().Be(minValue,
"DistroOval (Tier 1) should have the lowest enum value (highest priority)");
}
[Fact]
public void EvidenceTier_NvdRange_HasHighestEnumValue()
{
// Lower enum value = higher tier priority
var allTiers = Enum.GetValues<EvidenceTier>()
.Where(t => t != EvidenceTier.Unknown)
.ToList();
var maxValue = allTiers.Max(t => (int)t);
((int)EvidenceTier.NvdRange).Should().Be(maxValue,
"NvdRange (Tier 5) should have the highest enum value (lowest priority)");
}
[Theory]
[InlineData(EvidenceTier.DistroOval, 1)]
[InlineData(EvidenceTier.Changelog, 2)]
[InlineData(EvidenceTier.SourcePatch, 3)]
[InlineData(EvidenceTier.UpstreamCommit, 4)]
[InlineData(EvidenceTier.NvdRange, 5)]
public void EvidenceTier_HasCorrectTierNumber(EvidenceTier tier, int expectedTierNumber)
{
((int)tier).Should().Be(expectedTierNumber,
$"{tier} should be Tier {expectedTierNumber}");
}
#endregion
#region RulePriority to EvidenceTier Mapping Tests
[Theory]
[InlineData(RulePriority.DistroNativeOval, EvidenceTier.DistroOval)]
[InlineData(RulePriority.DerivativeOvalHigh, EvidenceTier.DistroOval)]
[InlineData(RulePriority.DerivativeOvalMedium, EvidenceTier.DistroOval)]
[InlineData(RulePriority.ChangelogExplicitCve, EvidenceTier.Changelog)]
[InlineData(RulePriority.ChangelogBugIdMapped, EvidenceTier.Changelog)]
[InlineData(RulePriority.SourcePatchExactMatch, EvidenceTier.SourcePatch)]
[InlineData(RulePriority.SourcePatchFuzzyMatch, EvidenceTier.SourcePatch)]
[InlineData(RulePriority.UpstreamCommitExactParity, EvidenceTier.UpstreamCommit)]
[InlineData(RulePriority.UpstreamCommitPartialMatch, EvidenceTier.UpstreamCommit)]
[InlineData(RulePriority.NvdRangeHeuristic, EvidenceTier.NvdRange)]
public void RulePriority_MapsToCorrectEvidenceTier(
RulePriority priority,
EvidenceTier expectedTier)
{
var actualTier = MapPriorityToTier(priority);
actualTier.Should().Be(expectedTier,
$"{priority} should map to {expectedTier}");
}
private static EvidenceTier MapPriorityToTier(RulePriority priority)
{
return priority switch
{
// Tier 1: OVAL/CSAF
RulePriority.DistroNativeOval or
RulePriority.DerivativeOvalHigh or
RulePriority.DerivativeOvalMedium or
RulePriority.DistroNative => EvidenceTier.DistroOval,
// Tier 2: Changelog
RulePriority.ChangelogExplicitCve or
RulePriority.ChangelogBugIdMapped or
RulePriority.VendorCsaf => EvidenceTier.Changelog,
// Tier 3: Source patches
RulePriority.SourcePatchExactMatch or
RulePriority.SourcePatchFuzzyMatch => EvidenceTier.SourcePatch,
// Tier 4: Upstream commits
RulePriority.UpstreamCommitExactParity or
RulePriority.UpstreamCommitPartialMatch => EvidenceTier.UpstreamCommit,
// Tier 5: NVD ranges
RulePriority.NvdRangeHeuristic or
RulePriority.ThirdParty => EvidenceTier.NvdRange,
_ => EvidenceTier.Unknown
};
}
#endregion
#region EvidencePointer with TierSource Tests
[Fact]
public void EvidencePointer_DefaultTierSource_IsUnknown()
{
var pointer = new EvidencePointer(
SourceType: "test",
SourceUrl: "https://example.com",
SourceDigest: null,
FetchedAt: DateTimeOffset.UtcNow);
pointer.TierSource.Should().Be(EvidenceTier.Unknown,
"Default TierSource should be Unknown");
}
[Fact]
public void EvidencePointer_CanSetTierSource_Explicitly()
{
var pointer = new EvidencePointer(
SourceType: "debian-tracker",
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-1234",
SourceDigest: "abc123",
FetchedAt: DateTimeOffset.UtcNow,
TierSource: EvidenceTier.DistroOval);
pointer.TierSource.Should().Be(EvidenceTier.DistroOval,
"TierSource should be set to DistroOval");
}
[Theory]
[InlineData(EvidenceTier.DistroOval, "debian-tracker")]
[InlineData(EvidenceTier.DistroOval, "alpine-secdb")]
[InlineData(EvidenceTier.DistroOval, "rhel-oval")]
[InlineData(EvidenceTier.Changelog, "changelog")]
[InlineData(EvidenceTier.SourcePatch, "patch-file")]
[InlineData(EvidenceTier.UpstreamCommit, "git-commit")]
[InlineData(EvidenceTier.NvdRange, "nvd-cpe")]
public void EvidencePointer_AuditTrail_IncludesTierSource(
EvidenceTier tier,
string sourceType)
{
var pointer = new EvidencePointer(
SourceType: sourceType,
SourceUrl: $"https://example.com/{sourceType}",
SourceDigest: "sha256:abc",
FetchedAt: DateTimeOffset.Parse("2025-01-01T12:00:00Z"),
TierSource: tier);
// Verify all properties are captured for audit
pointer.SourceType.Should().Be(sourceType);
pointer.TierSource.Should().Be(tier);
pointer.SourceDigest.Should().NotBeNullOrEmpty();
pointer.FetchedAt.Should().NotBe(default);
}
#endregion
#region Priority Selection Tests
[Fact]
public void SelectHighestPriority_FromMixedRules_ReturnsCorrectOrder()
{
// Arrange - create rules with different priorities
var rules = new List<(string Id, RulePriority Priority)>
{
("rule-nvd", RulePriority.NvdRangeHeuristic), // Tier 5
("rule-commit", RulePriority.UpstreamCommitPartialMatch), // Tier 4
("rule-patch", RulePriority.SourcePatchExactMatch), // Tier 3
("rule-changelog", RulePriority.ChangelogExplicitCve), // Tier 2
("rule-oval", RulePriority.DistroNativeOval) // Tier 1
};
// Act - sort by priority descending (highest priority first)
var sorted = rules.OrderByDescending(r => (int)r.Priority).ToList();
// Assert - Tier 1 should be first, Tier 5 last
sorted[0].Id.Should().Be("rule-oval", "OVAL (Tier 1) should be first");
sorted[1].Id.Should().Be("rule-changelog", "Changelog (Tier 2) should be second");
sorted[2].Id.Should().Be("rule-patch", "Patch (Tier 3) should be third");
sorted[3].Id.Should().Be("rule-commit", "Commit (Tier 4) should be fourth");
sorted[4].Id.Should().Be("rule-nvd", "NVD (Tier 5) should be last");
}
[Fact]
public void SelectHighestPriority_WithinSameTier_UsesSubPriority()
{
// Arrange - multiple rules within Tier 1
var tier1Rules = new List<(string Id, RulePriority Priority)>
{
("derivative-medium", RulePriority.DerivativeOvalMedium), // 90
("derivative-high", RulePriority.DerivativeOvalHigh), // 95
("native-oval", RulePriority.DistroNativeOval) // 100
};
// Act
var sorted = tier1Rules.OrderByDescending(r => (int)r.Priority).ToList();
// Assert - DistroNativeOval should be first within Tier 1
sorted[0].Id.Should().Be("native-oval");
sorted[1].Id.Should().Be("derivative-high");
sorted[2].Id.Should().Be("derivative-medium");
}
#endregion
}

View File

@@ -17,8 +17,11 @@
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DistroIntel/StellaOps.DistroIntel.csproj" />
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,6 +10,7 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -482,6 +483,10 @@ public sealed class JsonFeedExporterTests : IDisposable
var services = new ServiceCollection();
services.AddOptions();
services.Configure<CryptoHashOptions>(_ => { });
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddStellaOpsCrypto();
return services.BuildServiceProvider();
}

View File

@@ -79,22 +79,22 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
{
// Arrange
var advisoryKey = $"ADV-{Guid.NewGuid():N}";
var advisory1 = CreateAdvisory(advisoryKey, severity: "MEDIUM");
var advisory1 = CreateAdvisory(advisoryKey, severity: "medium");
await _advisoryRepository.UpsertAsync(advisory1);
var advisory2 = CreateAdvisory(advisoryKey, severity: "HIGH");
var advisory2 = CreateAdvisory(advisoryKey, severity: "high");
// Act
var result = await _advisoryRepository.UpsertAsync(advisory2);
// Assert - Should update the severity
result.Should().NotBeNull();
result.Severity.Should().Be("HIGH");
result.Severity.Should().Be("high");
// Verify only one record exists
var retrieved = await _advisoryRepository.GetByKeyAsync(advisoryKey);
retrieved.Should().NotBeNull();
retrieved!.Severity.Should().Be("HIGH");
retrieved!.Severity.Should().Be("high");
}
[Fact]
@@ -342,13 +342,23 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
Title = "Test Advisory",
Summary = "Test advisory summary",
Description = "Test advisory description",
Severity = severity ?? "MEDIUM",
Severity = NormalizeSeverity(severity) ?? "medium",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "test"}"""
};
}
private static string? NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return null;
}
return severity.Trim().ToLowerInvariant();
}
private static SourceEntity CreateSource(string sourceKey, int priority = 100)
{
return new SourceEntity

View File

@@ -73,7 +73,7 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
AdvisoryKey = advisory.AdvisoryKey,
PrimaryVulnId = advisory.PrimaryVulnId,
Title = "Updated Title",
Severity = "HIGH",
Severity = "high",
Summary = advisory.Summary,
Description = advisory.Description,
PublishedAt = advisory.PublishedAt,
@@ -87,7 +87,7 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
// Assert
result.Should().NotBeNull();
result.Title.Should().Be("Updated Title");
result.Severity.Should().Be("HIGH");
result.Severity.Should().Be("high");
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
@@ -312,8 +312,8 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
public async Task GetBySeverityAsync_ShouldReturnAdvisoriesWithMatchingSeverity()
{
// Arrange
var criticalAdvisory = CreateTestAdvisory(severity: "CRITICAL");
var lowAdvisory = CreateTestAdvisory(severity: "LOW");
var criticalAdvisory = CreateTestAdvisory(severity: "critical");
var lowAdvisory = CreateTestAdvisory(severity: "low");
await _repository.UpsertAsync(criticalAdvisory);
await _repository.UpsertAsync(lowAdvisory);
@@ -365,8 +365,8 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
public async Task CountBySeverityAsync_ShouldReturnCountsGroupedBySeverity()
{
// Arrange
var highAdvisory = CreateTestAdvisory(severity: "HIGH");
var mediumAdvisory = CreateTestAdvisory(severity: "MEDIUM");
var highAdvisory = CreateTestAdvisory(severity: "high");
var mediumAdvisory = CreateTestAdvisory(severity: "medium");
await _repository.UpsertAsync(highAdvisory);
await _repository.UpsertAsync(mediumAdvisory);
@@ -375,10 +375,10 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
var counts = await _repository.CountBySeverityAsync();
// Assert
counts.Should().ContainKey("HIGH");
counts.Should().ContainKey("MEDIUM");
counts["HIGH"].Should().BeGreaterThanOrEqualTo(1);
counts["MEDIUM"].Should().BeGreaterThanOrEqualTo(1);
counts.Should().ContainKey("high");
counts.Should().ContainKey("medium");
counts["high"].Should().BeGreaterThanOrEqualTo(1);
counts["medium"].Should().BeGreaterThanOrEqualTo(1);
}
[Trait("Category", TestCategories.Unit)]
@@ -454,12 +454,22 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
Title = "Test Advisory",
Summary = "This is a test advisory summary",
Description = "This is a detailed description of the test advisory",
Severity = severity ?? "MEDIUM",
Severity = NormalizeSeverity(severity) ?? "medium",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
Provenance = """{"source": "test"}"""
};
}
private static string? NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return null;
}
return severity.Trim().ToLowerInvariant();
}
}

View File

@@ -87,7 +87,7 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
{
// Arrange - Create multiple advisories with same severity
var advisories = Enumerable.Range(0, 5)
.Select(i => CreateAdvisory($"ADV-CRITICAL-{Guid.NewGuid():N}", severity: "CRITICAL"))
.Select(i => CreateAdvisory($"ADV-CRITICAL-{Guid.NewGuid():N}", severity: "critical"))
.ToList();
foreach (var advisory in advisories)
@@ -334,10 +334,10 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
public async Task CountBySeverityAsync_MultipleQueries_ReturnsConsistentCounts()
{
// Arrange
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "CRITICAL"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "CRITICAL"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "HIGH"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "MEDIUM"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "critical"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "critical"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "high"));
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "medium"));
// Act - Run multiple queries
var results1 = await _advisoryRepository.CountBySeverityAsync();
@@ -384,13 +384,23 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
Title = "Test Advisory",
Summary = "Test advisory summary",
Description = "Test advisory description",
Severity = severity ?? "MEDIUM",
Severity = NormalizeSeverity(severity) ?? "medium",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
Provenance = """{"source": "test"}"""
};
}
private static string? NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return null;
}
return severity.Trim().ToLowerInvariant();
}
private static SourceEntity CreateSource(string sourceKey, bool enabled = true, int priority = 100)
{
return new SourceEntity

View File

@@ -273,7 +273,7 @@ public sealed class KevFlagRepositoryTests : IAsyncLifetime
AdvisoryKey = $"KEV-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "KEV Test Advisory",
Severity = "CRITICAL",
Severity = "critical",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "kev-test"}"""

View File

@@ -273,7 +273,7 @@ public sealed class MergeEventRepositoryTests : IAsyncLifetime
AdvisoryKey = $"MERGE-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "Merge Event Test Advisory",
Severity = "HIGH",
Severity = "high",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "merge-test"}"""

View File

@@ -377,7 +377,7 @@ public sealed class AdvisoryPerformanceTests : IAsyncLifetime
AdvisoryKey = key,
PrimaryVulnId = $"CVE-2025-{key.GetHashCode():X8}"[..20],
Title = title ?? $"Test Advisory {key}",
Severity = "MEDIUM",
Severity = "medium",
Summary = $"Summary for {key}",
Description = description ?? $"Detailed description for test advisory {key}. This vulnerability affects multiple components.",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-Random.Shared.Next(1, 365)),

View File

@@ -0,0 +1,370 @@
// -----------------------------------------------------------------------------
// BugIdExtractionTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-408)
// Task: Unit tests for bug ID extraction regex patterns
// Description: Tests for Debian BTS, RHBZ, Launchpad bug reference extraction
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Concelier.SourceIntel;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.SourceIntel.Tests;
/// <summary>
/// Unit tests for bug ID extraction from changelog lines.
/// Validates regex patterns for Debian BTS, Red Hat Bugzilla, and Launchpad.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class BugIdExtractionTests
{
#region Debian BTS Tests
[Theory]
[InlineData("Closes: #123456", BugTracker.Debian, "123456")]
[InlineData("Closes: 123456", BugTracker.Debian, "123456")]
[InlineData("closes: #789012", BugTracker.Debian, "789012")]
[InlineData("(Closes: #999999)", BugTracker.Debian, "999999")]
[InlineData("Fixes: #123456", BugTracker.Debian, "123456")]
[InlineData("fixes: 654321", BugTracker.Debian, "654321")]
public void ExtractBugReferences_DebianSingleBug_ExtractsCorrectly(
string line,
BugTracker expectedTracker,
string expectedBugId)
{
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
bugs.Should().ContainSingle();
bugs[0].Tracker.Should().Be(expectedTracker);
bugs[0].BugId.Should().Be(expectedBugId);
}
[Fact]
public void ExtractBugReferences_DebianMultipleBugs_ExtractsAll()
{
// Arrange
var line = "Closes: #123456, #789012, #345678";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
bugs.Should().HaveCount(3);
bugs.Should().AllSatisfy(b => b.Tracker.Should().Be(BugTracker.Debian));
bugs.Select(b => b.BugId).Should().Contain("123456", "789012", "345678");
}
[Fact]
public void ExtractBugReferences_DebianInContext_ExtractsCorrectly()
{
// Arrange - realistic changelog line
var line = " * Fix buffer overflow vulnerability (Closes: #1045678)";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
bugs.Should().ContainSingle();
bugs[0].Tracker.Should().Be(BugTracker.Debian);
bugs[0].BugId.Should().Be("1045678");
}
#endregion
#region Red Hat Bugzilla Tests
[Theory]
[InlineData("RHBZ#1234567", BugTracker.RedHat, "1234567")]
[InlineData("rhbz#7654321", BugTracker.RedHat, "7654321")]
[InlineData("RHBZ #1234567", BugTracker.RedHat, "1234567")]
[InlineData("bz#1234567", BugTracker.RedHat, "1234567")]
[InlineData("BZ#1234567", BugTracker.RedHat, "1234567")]
[InlineData("Bug 1234567", BugTracker.RedHat, "1234567")]
[InlineData("Bug: 1234567", BugTracker.RedHat, "1234567")]
public void ExtractBugReferences_RedHatSingleBug_ExtractsCorrectly(
string line,
BugTracker expectedTracker,
string expectedBugId)
{
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert - filter to RedHat only in case other patterns match
var rhBugs = bugs.Where(b => b.Tracker == expectedTracker).ToList();
rhBugs.Should().NotBeEmpty();
rhBugs.Should().Contain(b => b.BugId == expectedBugId);
}
[Fact]
public void ExtractBugReferences_RedHatInChangelogContext_ExtractsCorrectly()
{
// Arrange - realistic RPM changelog line
var line = "- Fix security vulnerability (RHBZ#2145678, CVE-2024-1234)";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
var rhBugs = bugs.Where(b => b.Tracker == BugTracker.RedHat).ToList();
rhBugs.Should().ContainSingle();
rhBugs[0].BugId.Should().Be("2145678");
}
[Fact]
public void ExtractBugReferences_RedHatWithResolves_ExtractsCorrectly()
{
// Arrange
var line = "Resolves: RHBZ#2234567";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
var rhBugs = bugs.Where(b => b.Tracker == BugTracker.RedHat).ToList();
rhBugs.Should().ContainSingle();
rhBugs[0].BugId.Should().Be("2234567");
}
#endregion
#region Launchpad Tests
[Theory]
[InlineData("LP: #123456", BugTracker.Launchpad, "123456")]
[InlineData("LP #123456", BugTracker.Launchpad, "123456")]
[InlineData("LP:#123456", BugTracker.Launchpad, "123456")]
[InlineData("lp: #789012", BugTracker.Launchpad, "789012")]
public void ExtractBugReferences_LaunchpadSingleBug_ExtractsCorrectly(
string line,
BugTracker expectedTracker,
string expectedBugId)
{
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
var lpBugs = bugs.Where(b => b.Tracker == expectedTracker).ToList();
lpBugs.Should().NotBeEmpty();
lpBugs.Should().Contain(b => b.BugId == expectedBugId);
}
[Fact]
public void ExtractBugReferences_LaunchpadMultipleBugs_ExtractsAll()
{
// Arrange
var line = "* Fix multiple issues (LP: #2045123, LP: #2045124)";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
var lpBugs = bugs.Where(b => b.Tracker == BugTracker.Launchpad).ToList();
lpBugs.Should().HaveCount(2);
lpBugs.Select(b => b.BugId).Should().Contain("2045123", "2045124");
}
[Fact]
public void ExtractBugReferences_UbuntuChangelog_ExtractsCorrectly()
{
// Arrange - realistic Ubuntu changelog line
var line = " - d/p/fix-crash.patch: Fix crash on startup (LP: #2087654)";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
var lpBugs = bugs.Where(b => b.Tracker == BugTracker.Launchpad).ToList();
lpBugs.Should().ContainSingle();
lpBugs[0].BugId.Should().Be("2087654");
}
#endregion
#region Mixed Tracker Tests
[Fact]
public void ExtractBugReferences_MultipleTrackers_ExtractsAll()
{
// Arrange - line with Debian and Launchpad references
var line = "Fix security issue (Closes: #1045678) (LP: #2087654)";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
bugs.Should().HaveCountGreaterThanOrEqualTo(2);
bugs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "1045678");
bugs.Should().Contain(b => b.Tracker == BugTracker.Launchpad && b.BugId == "2087654");
}
[Fact]
public void ExtractBugReferences_NoReferences_ReturnsEmpty()
{
// Arrange
var line = " * Bump standards version to 4.6.0";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
bugs.Should().BeEmpty();
}
[Fact]
public void ExtractBugReferences_CveOnly_ReturnsEmpty()
{
// Arrange - CVE but no bug tracker reference
var line = " * Fix CVE-2024-1234";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert - CVEs are not bug references (handled separately)
bugs.Should().BeEmpty();
}
#endregion
#region Edge Cases
[Fact]
public void ExtractBugReferences_EmptyString_ReturnsEmpty()
{
// Act
var bugs = ChangelogParser.ExtractBugReferences("");
// Assert
bugs.Should().BeEmpty();
}
[Fact]
public void ExtractBugReferences_WhitespaceOnly_ReturnsEmpty()
{
// Act
var bugs = ChangelogParser.ExtractBugReferences(" \t\n ");
// Assert
bugs.Should().BeEmpty();
}
[Fact]
public void ExtractBugReferences_InvalidBugFormat_ReturnsEmpty()
{
// Arrange - things that look like bugs but aren't
var line = "Bug: yes, Closes: the door";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert - shouldn't match text without numbers
bugs.Should().BeEmpty();
}
[Fact]
public void ExtractBugReferences_BugIdTooShort_HandlesCorrectly()
{
// Arrange - Debian accepts reasonable IDs (4+ digits), RHBZ typically has 6-8 digits
// Very short bug IDs (<4 digits) are ignored to avoid false positives
var line = "Closes: #12345 and also RHBZ#12345678";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert
// Debian should capture IDs with 4+ digits
bugs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "12345");
// RHBZ should capture the longer ID
bugs.Should().Contain(b => b.Tracker == BugTracker.RedHat && b.BugId == "12345678");
}
[Fact]
public void ExtractBugReferences_VeryShortBugId_Ignored()
{
// Arrange - very short bug IDs (<4 digits) should be ignored to avoid false positives
var line = "Closes: #123";
// Act
var bugs = ChangelogParser.ExtractBugReferences(line);
// Assert - 3-digit IDs are ignored
bugs.Should().BeEmpty();
}
#endregion
#region Changelog Integration Tests
[Fact]
public void ParseDebianChangelog_WithBugReferences_ExtractsBugs()
{
// Arrange
var changelog = @"
curl (7.88.1-10+deb12u5) bookworm-security; urgency=high
* Fix buffer overflow (CVE-2024-1234)
* Backport patch from upstream (Closes: #1045678)
-- Security Team <team@debian.org> Mon, 15 Jan 2024 10:00:00 +0000
";
// Act
var result = ChangelogParser.ParseDebianChangelog(changelog);
// Assert
result.Entries.Should().ContainSingle();
result.Entries[0].CveIds.Should().Contain("CVE-2024-1234");
result.Entries[0].BugReferences.Should().ContainSingle();
result.Entries[0].BugReferences[0].Tracker.Should().Be(BugTracker.Debian);
result.Entries[0].BugReferences[0].BugId.Should().Be("1045678");
}
[Fact]
public void ParseRpmChangelog_WithBugReferences_ExtractsBugs()
{
// Arrange
var changelog = @"
* Mon Jan 15 2024 Security Team <team@redhat.com> - 7.76.1-26.el9_3.2
- Fix CVE-2024-1234 (RHBZ#2145678)
- Backport upstream patch
";
// Act
var result = ChangelogParser.ParseRpmChangelog(changelog);
// Assert
result.Entries.Should().ContainSingle();
result.Entries[0].CveIds.Should().Contain("CVE-2024-1234");
result.Entries[0].BugReferences.Should().ContainSingle();
result.Entries[0].BugReferences[0].Tracker.Should().Be(BugTracker.RedHat);
result.Entries[0].BugReferences[0].BugId.Should().Be("2145678");
}
[Fact]
public void ParseDebianChangelog_BugOnlyEntry_ExtractsBugs()
{
// Arrange - entry with bug reference but no CVE
var changelog = @"
curl (7.88.1-10+deb12u4) bookworm; urgency=medium
* Fix crash on specific input (Closes: #1045000)
-- Maintainer <maint@debian.org> Thu, 10 Jan 2024 08:00:00 +0000
";
// Act
var result = ChangelogParser.ParseDebianChangelog(changelog);
// Assert
result.Entries.Should().ContainSingle();
result.Entries[0].CveIds.Should().BeEmpty();
result.Entries[0].BugReferences.Should().ContainSingle();
result.Entries[0].BugReferences[0].BugId.Should().Be("1045000");
// Bug-only entries should have lower confidence
result.Entries[0].Confidence.Should().BeLessThan(0.80);
}
#endregion
}

View File

@@ -32,7 +32,7 @@ public sealed class CanonicalAdvisoryEndpointTests : IAsyncLifetime
public ValueTask InitializeAsync()
{
_factory = new WebApplicationFactory<Program>()
_factory = new ConcelierApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");

View File

@@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.WebService.Options;
using Xunit;
@@ -48,6 +50,8 @@ public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
{
PostgresStorage = new ConcelierOptions.PostgresStorageOptions

View File

@@ -1,18 +1,18 @@
using System.Net;
using System.Net.Http.Headers;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Concelier.WebService.Tests.Fixtures;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Concelier.WebService.Tests;
public class ConcelierTimelineCursorTests : IClassFixture<WebApplicationFactory<Program>>
public class ConcelierTimelineCursorTests : IClassFixture<ConcelierApplicationFactory>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ConcelierApplicationFactory _factory;
public ConcelierTimelineCursorTests(WebApplicationFactory<Program> factory)
public ConcelierTimelineCursorTests(ConcelierApplicationFactory factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}

View File

@@ -1,18 +1,18 @@
using System.Net;
using System.Net.Http.Headers;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Concelier.WebService.Tests.Fixtures;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Concelier.WebService.Tests;
public class ConcelierTimelineEndpointTests : IClassFixture<WebApplicationFactory<Program>>
public class ConcelierTimelineEndpointTests : IClassFixture<ConcelierApplicationFactory>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ConcelierApplicationFactory _factory;
public ConcelierTimelineEndpointTests(WebApplicationFactory<Program> factory)
public ConcelierTimelineEndpointTests(ConcelierApplicationFactory factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}

View File

@@ -9,7 +9,9 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
@@ -63,6 +65,8 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, TestLeaseStore>();
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
{
PostgresStorage = new ConcelierOptions.PostgresStorageOptions

View File

@@ -0,0 +1,63 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
internal sealed class TestLeaseStore : ILeaseStore
{
private readonly object _lock = new();
private readonly Dictionary<string, JobLease> _leases = new();
public Task<JobLease?> TryAcquireAsync(
string key,
string holder,
TimeSpan leaseDuration,
DateTimeOffset now,
CancellationToken cancellationToken)
{
lock (_lock)
{
if (_leases.TryGetValue(key, out var existing) && existing.TtlAt > now && existing.Holder != holder)
{
return Task.FromResult<JobLease?>(null);
}
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
_leases[key] = lease;
return Task.FromResult<JobLease?>(lease);
}
}
public Task<JobLease?> HeartbeatAsync(
string key,
string holder,
TimeSpan leaseDuration,
DateTimeOffset now,
CancellationToken cancellationToken)
{
lock (_lock)
{
if (_leases.TryGetValue(key, out var existing) && existing.Holder == holder)
{
var lease = new JobLease(key, holder, existing.AcquiredAt, now, leaseDuration, now.Add(leaseDuration));
_leases[key] = lease;
return Task.FromResult<JobLease?>(lease);
}
return Task.FromResult<JobLease?>(null);
}
}
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
{
lock (_lock)
{
if (_leases.TryGetValue(key, out var existing) && existing.Holder == holder)
{
_leases.Remove(key);
return Task.FromResult(true);
}
}
return Task.FromResult(false);
}
}

View File

@@ -11,8 +11,10 @@ using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Interest.Models;
using Xunit;
@@ -338,6 +340,8 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
builder.ConfigureServices(services =>
{
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
// Remove existing registrations
var scoringServiceDescriptor = services
.SingleOrDefault(d => d.ServiceType == typeof(IInterestScoringService));

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Core.Orchestration;
using StellaOps.Concelier.WebService;
using StellaOps.Concelier.WebService.Options;
@@ -51,6 +52,8 @@ public sealed class OrchestratorTestWebAppFactory : WebApplicationFactory<Progra
builder.ConfigureServices(services =>
{
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
services.RemoveAll<IOrchestratorRegistryStore>();
services.AddSingleton<IOrchestratorRegistryStore, InMemoryOrchestratorRegistryStore>();

View File

@@ -2091,6 +2091,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
builder.ConfigureServices(services =>
{
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
services.AddSingleton<StubJobCoordinator>();
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
services.PostConfigure<ConcelierOptions>(options =>