up
Some checks failed
Feedser CI / build-and-test (push) Has been cancelled

This commit is contained in:
2025-10-06 01:13:41 +03:00
parent b6ef66e057
commit bb7eda17a8
163 changed files with 801 additions and 248 deletions

View File

@@ -0,0 +1,95 @@
{
"document": {
"aggregate_severity": {
"text": "Important"
},
"lang": "en",
"notes": [
{
"category": "summary",
"text": "An update fixes a critical kernel issue."
}
],
"references": [
{
"category": "self",
"summary": "RHSA advisory",
"url": "https://access.redhat.com/errata/RHSA-2025:0001"
}
],
"title": "Red Hat Security Advisory: Example kernel update",
"tracking": {
"id": "RHSA-2025:0001",
"initial_release_date": "2025-10-02T00:00:00+00:00",
"current_release_date": "2025-10-03T00:00:00+00:00"
}
},
"product_tree": {
"branches": [
{
"category": "product_family",
"branches": [
{
"category": "product_name",
"product": {
"name": "Red Hat Enterprise Linux 8",
"product_id": "8Base-RHEL-8",
"product_identification_helper": {
"cpe": "cpe:/o:redhat:enterprise_linux:8"
}
}
}
]
},
{
"category": "product_release",
"branches": [
{
"category": "product_version",
"product": {
"name": "kernel-0:4.18.0-513.5.1.el8.x86_64",
"product_id": "kernel-0:4.18.0-513.5.1.el8.x86_64",
"product_identification_helper": {
"purl": "pkg:rpm/redhat/kernel@4.18.0-513.5.1.el8?arch=x86_64"
}
}
}
]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-0001",
"references": [
{
"category": "external",
"summary": "CVE record",
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0001"
}
],
"scores": [
{
"cvss_v3": {
"baseScore": 9.8,
"baseSeverity": "CRITICAL",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
}
}
],
"product_status": {
"fixed": [
"8Base-RHEL-8:kernel-0:4.18.0-513.5.1.el8.x86_64"
],
"first_fixed": [
"8Base-RHEL-8:kernel-0:4.18.0-513.5.1.el8.x86_64"
],
"known_affected": [
"8Base-RHEL-8",
"8Base-RHEL-8:kernel-0:4.18.0-500.1.0.el8.x86_64"
]
}
}
]
}

View File

@@ -0,0 +1,82 @@
{
"document": {
"aggregate_severity": {
"text": "Moderate"
},
"lang": "en",
"notes": [
{
"category": "summary",
"text": "Second advisory covering unaffected packages."
}
],
"references": [
{
"category": "self",
"summary": "RHSA advisory",
"url": "https://access.redhat.com/errata/RHSA-2025:0002"
}
],
"title": "Red Hat Security Advisory: Follow-up kernel status",
"tracking": {
"id": "RHSA-2025:0002",
"initial_release_date": "2025-10-05T12:00:00+00:00",
"current_release_date": "2025-10-05T12:00:00+00:00"
}
},
"product_tree": {
"branches": [
{
"category": "product_family",
"branches": [
{
"category": "product_name",
"product": {
"name": "Red Hat Enterprise Linux 9",
"product_id": "9Base-RHEL-9",
"product_identification_helper": {
"cpe": "cpe:/o:redhat:enterprise_linux:9"
}
}
}
]
},
{
"category": "product_release",
"branches": [
{
"category": "product_version",
"product": {
"name": "kernel-0:5.14.0-400.el9.x86_64",
"product_id": "kernel-0:5.14.0-400.el9.x86_64",
"product_identification_helper": {
"purl": "pkg:rpm/redhat/kernel@5.14.0-400.el9?arch=x86_64"
}
}
}
]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-0002",
"references": [
{
"category": "external",
"summary": "CVE record",
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0002"
}
],
"product_status": {
"known_not_affected": [
"9Base-RHEL-9",
"9Base-RHEL-9:kernel-0:5.14.0-400.el9.x86_64"
],
"under_investigation": [
"9Base-RHEL-9:kernel-0:5.14.0-401.el9.x86_64"
]
}
}
]
}

View File

@@ -0,0 +1,93 @@
{
"document": {
"aggregate_severity": {
"text": "Important"
},
"lang": "en",
"notes": [
{
"category": "summary",
"text": "Advisory with mixed reference sources to verify dedupe ordering."
}
],
"references": [
{
"category": "self",
"summary": "Primary advisory",
"url": "https://access.redhat.com/errata/RHSA-2025:0003"
},
{
"category": "self",
"summary": "",
"url": "https://access.redhat.com/errata/RHSA-2025:0003"
},
{
"category": "mitigation",
"summary": "Knowledge base guidance",
"url": "https://access.redhat.com/solutions/999999"
}
],
"title": "Red Hat Security Advisory: Reference dedupe validation",
"tracking": {
"id": "RHSA-2025:0003",
"initial_release_date": "2025-10-06T09:00:00+00:00",
"current_release_date": "2025-10-06T09:00:00+00:00"
}
},
"product_tree": {
"branches": [
{
"category": "product_family",
"branches": [
{
"category": "product_name",
"product": {
"name": "Red Hat Enterprise Linux 9",
"product_id": "9Base-RHEL-9",
"product_identification_helper": {
"cpe": "cpe:/o:redhat:enterprise_linux:9"
}
}
}
]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-0003",
"references": [
{
"category": "external",
"summary": "CVE record",
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0003"
},
{
"category": "external",
"summary": "",
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0003"
},
{
"category": "exploit",
"summary": "Exploit tracking",
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2222222"
}
],
"scores": [
{
"cvss_v3": {
"baseScore": 7.5,
"baseSeverity": "HIGH",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"version": "3.1"
}
}
],
"product_status": {
"known_affected": [
"9Base-RHEL-9"
]
}
}
]
}

View File

@@ -0,0 +1,118 @@
{
"advisoryKey": "RHSA-2025:0001",
"affectedPackages": [
{
"identifier": "cpe:2.3:o:redhat:enterprise_linux:8:*:*:*:*:*:*:*",
"platform": "Red Hat Enterprise Linux 8",
"provenance": [
{
"kind": "oval",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "8Base-RHEL-8"
}
],
"statuses": [
{
"provenance": {
"kind": "oval",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "8Base-RHEL-8"
},
"status": "known_affected"
}
],
"type": "cpe",
"versionRanges": []
},
{
"identifier": "kernel-0:4.18.0-513.5.1.el8.x86_64",
"platform": "Red Hat Enterprise Linux 8",
"provenance": [
{
"kind": "package.nevra",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "kernel-0:4.18.0-513.5.1.el8.x86_64"
}
],
"statuses": [],
"type": "rpm",
"versionRanges": [
{
"fixedVersion": "kernel-0:4.18.0-513.5.1.el8.x86_64",
"introducedVersion": null,
"lastAffectedVersion": null,
"provenance": {
"kind": "package.nevra",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "kernel-0:4.18.0-513.5.1.el8.x86_64"
},
"rangeExpression": null,
"rangeKind": "nevra"
}
]
}
],
"aliases": [
"CVE-2025-0001",
"RHSA-2025:0001"
],
"cvssMetrics": [
{
"baseScore": 9.8,
"baseSeverity": "critical",
"provenance": {
"kind": "cvss",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "CVE-2025-0001"
},
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"version": "3.1"
}
],
"exploitKnown": false,
"language": "en",
"modified": "2025-10-03T00:00:00+00:00",
"provenance": [
{
"kind": "advisory",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "RHSA-2025:0001"
}
],
"published": "2025-10-02T00:00:00+00:00",
"references": [
{
"kind": "self",
"provenance": {
"kind": "reference",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "https://access.redhat.com/errata/RHSA-2025:0001"
},
"sourceTag": null,
"summary": "RHSA advisory",
"url": "https://access.redhat.com/errata/RHSA-2025:0001"
},
{
"kind": "external",
"provenance": {
"kind": "reference",
"recordedAt": "2025-10-05T00:00:00+00:00",
"source": "redhat",
"value": "https://www.cve.org/CVERecord?id=CVE-2025-0001"
},
"sourceTag": null,
"summary": "CVE record",
"url": "https://www.cve.org/CVERecord?id=CVE-2025-0001"
}
],
"severity": "high",
"summary": "An update fixes a critical kernel issue.",
"title": "Red Hat Security Advisory: Example kernel update"
}

View File

@@ -0,0 +1,8 @@
[
{
"RHSA": "RHSA-2025:0001",
"severity": "important",
"released_on": "2025-10-03T00:00:00Z",
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json"
}
]

View File

@@ -0,0 +1,8 @@
[
{
"RHSA": "RHSA-2025:0001",
"severity": "important",
"released_on": "2025-10-03T12:00:00Z",
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json"
}
]

View File

@@ -0,0 +1,8 @@
[
{
"RHSA": "RHSA-2025:0002",
"severity": "moderate",
"released_on": "2025-10-05T12:00:00Z",
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json"
}
]

View File

@@ -0,0 +1,8 @@
[
{
"RHSA": "RHSA-2025:0003",
"severity": "important",
"released_on": "2025-10-06T09:00:00Z",
"resource_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0003.json"
}
]

View File

@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Bson;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Distro.RedHat;
using StellaOps.Feedser.Source.Distro.RedHat.Configuration;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Testing;
using StellaOps.Feedser.Testing;
namespace StellaOps.Feedser.Source.Distro.RedHat.Tests;
[Collection("mongo-fixture")]
public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public RedHatConnectorHarnessTests(MongoIntegrationFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero), RedHatOptions.HttpClientName);
}
[Fact]
public async Task FetchParseMap_WithHarness_ProducesCanonicalAdvisory()
{
await _harness.ResetAsync();
var options = new RedHatOptions
{
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
PageSize = 1,
MaxPagesPerFetch = 1,
MaxAdvisoriesPerFetch = 5,
InitialBackfill = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
FetchTimeout = TimeSpan.FromSeconds(30),
UserAgent = "StellaOps.Tests.RedHatHarness/1.0",
};
var handler = _harness.Handler;
var timeProvider = _harness.TimeProvider;
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=1&page=1");
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
handler.AddJsonResponse(summaryUri, ReadFixture("summary-page1.json"));
handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddRedHatConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.PageSize = options.PageSize;
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
opts.InitialBackfill = options.InitialBackfill;
opts.Overlap = options.Overlap;
opts.FetchTimeout = options.FetchTimeout;
opts.UserAgent = options.UserAgent;
});
});
var provider = _harness.ServiceProvider;
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Single(advisories);
var advisory = advisories.Single();
Assert.Equal("RHSA-2025:0001", advisory.AdvisoryKey);
Assert.Equal("high", advisory.Severity);
Assert.Contains(advisory.Aliases, alias => alias == "CVE-2025-0001");
Assert.Empty(advisory.Provenance.Where(p => p.Source == "redhat" && p.Kind == "fetch"));
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsBsonArray.Count == 0);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static string ReadFixture(string filename)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", filename);
return File.ReadAllText(path);
}
}

View File

@@ -0,0 +1,449 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Bson;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Core.Jobs;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Common.Http;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Distro.RedHat;
using StellaOps.Feedser.Source.Distro.RedHat.Configuration;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Testing;
using StellaOps.Plugin;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Feedser.Source.Distro.RedHat.Tests;
[Collection("mongo-fixture")]
public sealed class RedHatConnectorTests : IAsyncLifetime
{
private readonly MongoIntegrationFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly DateTimeOffset _initialNow;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
private ServiceProvider? _serviceProvider;
public RedHatConnectorTests(MongoIntegrationFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_initialNow = new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero);
_timeProvider = new FakeTimeProvider(_initialNow);
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await ResetDatabaseAsync();
var options = new RedHatOptions
{
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
PageSize = 1,
MaxPagesPerFetch = 1,
MaxAdvisoriesPerFetch = 25,
InitialBackfill = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
FetchTimeout = TimeSpan.FromSeconds(30),
UserAgent = "StellaOps.Tests.RedHat/1.0",
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var configuredOptions = provider.GetRequiredService<IOptions<RedHatOptions>>().Value;
Assert.Equal(1, configuredOptions.PageSize);
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=1&page=1");
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
_output.WriteLine($"Registering summary URI: {summaryUri}");
_handler.AddJsonResponse(summaryUri, ReadFixture("summary-page1.json"));
_handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: _timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var advisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0001", StringComparison.Ordinal));
Assert.Equal("red hat security advisory: example kernel update", advisory.Title.ToLowerInvariant());
Assert.Contains("RHSA-2025:0001", advisory.Aliases);
Assert.Contains("CVE-2025-0001", advisory.Aliases);
Assert.Equal("high", advisory.Severity);
Assert.Equal("en", advisory.Language);
var rpmPackage = advisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Rpm);
_output.WriteLine($"RPM statuses count: {rpmPackage.Statuses.Length}");
_output.WriteLine($"RPM ranges count: {rpmPackage.VersionRanges.Length}");
foreach (var range in rpmPackage.VersionRanges)
{
_output.WriteLine($"Range fixed={range.FixedVersion}, last={range.LastAffectedVersion}, expr={range.RangeExpression}");
}
Assert.Equal("kernel-0:4.18.0-513.5.1.el8.x86_64", rpmPackage.Identifier);
var fixedRange = Assert.Single(
rpmPackage.VersionRanges,
range => string.Equals(range.FixedVersion, "kernel-0:4.18.0-513.5.1.el8.x86_64", StringComparison.Ordinal));
Assert.Equal("kernel-0:4.18.0-500.1.0.el8.x86_64", fixedRange.LastAffectedVersion);
var cpePackage = advisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Cpe);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:8:*:*:*:*:*:*:*", cpePackage.Identifier);
Assert.Contains(advisory.References, reference => reference.Url == "https://access.redhat.com/errata/RHSA-2025:0001");
Assert.Contains(advisory.References, reference => reference.Url == "https://www.cve.org/CVERecord?id=CVE-2025-0001");
var snapshot = SnapshotSerializer.ToSnapshot(advisory);
_output.WriteLine("-- RHSA-2025:0001 snapshot --\n" + snapshot);
var snapshotPath = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", "rhsa-2025-0001.snapshot.json");
var expectedSnapshot = File.ReadAllText(snapshotPath);
Assert.Equal(expectedSnapshot, snapshot);
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsBsonArray.Count == 0);
const string fetchKind = "source:redhat:fetch";
const string parseKind = "source:redhat:parse";
const string mapKind = "source:redhat:map";
var schedulerOptions = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(fetchKind, out var fetchDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(parseKind, out var parseDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(mapKind, out var mapDefinition));
Assert.Equal("RedHatFetchJob", fetchDefinition.JobType.Name);
Assert.Equal(TimeSpan.FromMinutes(12), fetchDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(6), fetchDefinition.LeaseDuration);
Assert.Equal("0,15,30,45 * * * *", fetchDefinition.CronExpression);
Assert.True(fetchDefinition.Enabled);
Assert.Equal("RedHatParseJob", parseDefinition.JobType.Name);
Assert.Equal(TimeSpan.FromMinutes(15), parseDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(6), parseDefinition.LeaseDuration);
Assert.Equal("5,20,35,50 * * * *", parseDefinition.CronExpression);
Assert.True(parseDefinition.Enabled);
Assert.Equal("RedHatMapJob", mapDefinition.JobType.Name);
Assert.Equal(TimeSpan.FromMinutes(20), mapDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(6), mapDefinition.LeaseDuration);
Assert.Equal("10,25,40,55 * * * *", mapDefinition.CronExpression);
Assert.True(mapDefinition.Enabled);
var summaryUriRepeat = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-03&per_page=10&page=1");
var summaryUriSecondPage = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-03&per_page=10&page=2");
var detailUri2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0002.json");
_output.WriteLine($"Registering repeat summary URI: {summaryUriRepeat}");
_output.WriteLine($"Registering second page summary URI: {summaryUriSecondPage}");
_handler.AddJsonResponse(summaryUriRepeat, ReadFixture("summary-page1-repeat.json"));
_handler.AddJsonResponse(summaryUriSecondPage, ReadFixture("summary-page2.json"));
_handler.AddJsonResponse(detailUri2, ReadFixture("csaf-rhsa-2025-0002.json"));
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var secondAdvisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0002", StringComparison.Ordinal));
var rpm2 = secondAdvisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Rpm);
Assert.Equal("kernel-0:5.14.0-400.el9.x86_64", rpm2.Identifier);
const string knownNotAffected = "known_not_affected";
const string underInvestigation = "under_investigation";
Assert.DoesNotContain(rpm2.VersionRanges, range => string.Equals(range.RangeExpression, knownNotAffected, StringComparison.Ordinal));
Assert.DoesNotContain(rpm2.VersionRanges, range => string.Equals(range.RangeExpression, underInvestigation, StringComparison.Ordinal));
Assert.Contains(rpm2.Statuses, status => status.Status == knownNotAffected);
Assert.Contains(rpm2.Statuses, status => status.Status == underInvestigation);
var cpe2 = secondAdvisory.AffectedPackages.Single(pkg => pkg.Type == AffectedPackageTypes.Cpe);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", cpe2.Identifier);
Assert.Empty(cpe2.VersionRanges);
Assert.Contains(cpe2.Statuses, status => status.Status == knownNotAffected);
state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out pendingMappings) && pendingMappings.AsBsonArray.Count == 0);
}
[Fact]
public async Task Resume_CompletesPendingDocumentsAfterRestart()
{
await ResetDatabaseAsync();
var options = new RedHatOptions
{
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
PageSize = 1,
MaxPagesPerFetch = 1,
MaxAdvisoriesPerFetch = 25,
InitialBackfill = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
FetchTimeout = TimeSpan.FromSeconds(30),
UserAgent = "StellaOps.Tests.RedHat/1.0",
};
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=1&page=1");
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0001.json");
var fetchHandler = new CannedHttpMessageHandler();
fetchHandler.AddJsonResponse(summaryUri, ReadFixture("summary-page1.json"));
fetchHandler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
Guid[] pendingDocumentIds;
await using (var fetchProvider = await CreateServiceProviderAsync(options, fetchHandler))
{
var stateRepository = fetchProvider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: _timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(fetchProvider);
await connector.FetchAsync(fetchProvider, CancellationToken.None);
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
Assert.NotEmpty(pendingDocs);
pendingDocumentIds = pendingDocs.Select(value => Guid.Parse(value.AsString)).ToArray();
}
var resumeHandler = new CannedHttpMessageHandler();
await using (var resumeProvider = await CreateServiceProviderAsync(options, resumeHandler))
{
var resumeConnector = new RedHatConnectorPlugin().Create(resumeProvider);
await resumeConnector.ParseAsync(resumeProvider, CancellationToken.None);
await resumeConnector.MapAsync(resumeProvider, CancellationToken.None);
var documentStore = resumeProvider.GetRequiredService<IDocumentStore>();
foreach (var documentId in pendingDocumentIds)
{
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
}
var advisoryStore = resumeProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.NotEmpty(advisories);
var stateRepository = resumeProvider.GetRequiredService<ISourceStateRepository>();
var finalState = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var finalPendingDocs = finalState!.Cursor.TryGetValue("pendingDocuments", out var docsValue) ? docsValue.AsBsonArray : new BsonArray();
Assert.Empty(finalPendingDocs);
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var mappingsValue) ? mappingsValue.AsBsonArray : new BsonArray();
Assert.Empty(finalPendingMappings);
}
}
[Fact]
public async Task MapAsync_DeduplicatesReferencesAndOrdersDeterministically()
{
await ResetDatabaseAsync();
var options = new RedHatOptions
{
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
PageSize = 1,
MaxPagesPerFetch = 1,
MaxAdvisoriesPerFetch = 10,
InitialBackfill = TimeSpan.FromDays(7),
Overlap = TimeSpan.Zero,
FetchTimeout = TimeSpan.FromSeconds(30),
UserAgent = "StellaOps.Tests.RedHat/1.0",
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=1&page=1");
var detailUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2025:0003.json");
_handler.AddJsonResponse(summaryUri, ReadFixture("summary-page3.json"));
_handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0003.json"));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: _timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisory = (await advisoryStore.GetRecentAsync(10, CancellationToken.None))
.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0003", StringComparison.Ordinal));
var references = advisory.References.ToArray();
Assert.Equal(4, references.Length);
Assert.Equal("exploit", references[0].Kind);
Assert.Equal("https://bugzilla.redhat.com/show_bug.cgi?id=2222222", references[0].Url);
Assert.Equal("external", references[1].Kind);
Assert.Equal("https://www.cve.org/CVERecord?id=CVE-2025-0003", references[1].Url);
Assert.Equal("CVE record", references[1].Summary);
Assert.Equal("mitigation", references[2].Kind);
Assert.Equal("https://access.redhat.com/solutions/999999", references[2].Url);
Assert.Equal("Knowledge base guidance", references[2].Summary);
Assert.Equal("self", references[3].Kind);
Assert.Equal("https://access.redhat.com/errata/RHSA-2025:0003", references[3].Url);
Assert.Equal("Primary advisory", references[3].Summary);
}
private async Task EnsureServiceProviderAsync(RedHatOptions options)
{
if (_serviceProvider is not null)
{
return;
}
_serviceProvider = await CreateServiceProviderAsync(options, _handler);
}
private async Task<ServiceProvider> CreateServiceProviderAsync(RedHatOptions options, CannedHttpMessageHandler handler)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = _fixture.Runner.ConnectionString;
storageOptions.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(5);
});
services.AddSourceCommon();
services.AddRedHatConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.PageSize = options.PageSize;
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
opts.InitialBackfill = options.InitialBackfill;
opts.Overlap = options.Overlap;
opts.FetchTimeout = options.FetchTimeout;
opts.UserAgent = options.UserAgent;
});
services.Configure<HttpClientFactoryOptions>(RedHatOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
await bootstrapper.InitializeAsync(CancellationToken.None);
return provider;
}
private Task ResetDatabaseAsync()
{
return ResetDatabaseInternalAsync();
}
private async Task ResetDatabaseInternalAsync()
{
if (_serviceProvider is not null)
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
_handler.Clear();
_timeProvider.SetUtcNow(_initialNow);
}
private static string ReadFixture(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", name);
return File.ReadAllText(path);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await ResetDatabaseInternalAsync();
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Source.Common/StellaOps.Feedser.Source.Common.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Source.Distro.RedHat/StellaOps.Feedser.Source.Distro.RedHat.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Storage.Mongo/StellaOps.Feedser.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="RedHat/Fixtures/*.json" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>