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

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

View File

@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.Collections.Generic;
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.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
@@ -22,238 +22,238 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorFetchTests : IAsyncLifetime
{
private const string TestNoteId = "294418";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertCcConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")]
public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor()
{
var template = new CertCcOptions
{
BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute),
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(5),
InitialBackfill = TimeSpan.FromDays(60),
MinimumWindowSize = TimeSpan.FromDays(1),
},
MaxMonthlySummaries = 3,
MaxNotesPerFetch = 3,
DetailRequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(template);
var provider = _serviceProvider!;
_handler.Clear();
var planner = provider.GetRequiredService<CertCcSummaryPlanner>();
var plan = planner.CreatePlan(state: null);
Assert.NotEmpty(plan.Requests);
foreach (var request in plan.Requests)
{
_handler.AddJsonResponse(request.Uri, BuildSummaryPayload());
}
RegisterDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
foreach (var request in plan.Requests)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]);
Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]);
if (request.Month.HasValue)
{
Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]);
}
else
{
Assert.False(record.Metadata.ContainsKey("certcc.month"));
}
}
foreach (var uri in EnumerateDetailUris())
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]);
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
BsonValue summaryValue;
Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue));
var summaryDocument = Assert.IsType<BsonDocument>(summaryValue);
Assert.True(summaryDocument.TryGetValue("start", out _));
Assert.True(summaryDocument.TryGetValue("end", out _));
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsBsonArray.Count
: 0;
Assert.Equal(0, pendingNotesCount);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsBsonArray.Count
: 0;
Assert.Equal(0, pendingSummariesCount);
Assert.True(state.Cursor.TryGetValue("lastRun", out _));
Assert.True(_handler.Requests.Count >= plan.Requests.Count);
foreach (var request in _handler.Requests)
{
if (request.Headers.TryGetValue("Accept", out var accept))
{
Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase);
}
}
}
private static string BuildSummaryPayload()
{
return $$"""
{
"count": 1,
"notes": [
"VU#{TestNoteId}"
]
}
""";
}
private void RegisterDetailResponses()
{
foreach (var uri in EnumerateDetailUris())
{
var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vendors.json"
: uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vuls.json"
: "vu-294418.json";
_handler.AddJsonResponse(uri, ReadFixture(fixtureName));
}
}
private static IEnumerable<Uri> EnumerateDetailUris()
{
var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
yield return new Uri(baseUri, $"{TestNoteId}/");
yield return new Uri(baseUri, $"{TestNoteId}/vendors/");
yield return new Uri(baseUri, $"{TestNoteId}/vuls/");
}
public sealed class CertCcConnectorFetchTests : IAsyncLifetime
{
private const string TestNoteId = "294418";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertCcConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")]
public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor()
{
var template = new CertCcOptions
{
BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute),
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(5),
InitialBackfill = TimeSpan.FromDays(60),
MinimumWindowSize = TimeSpan.FromDays(1),
},
MaxMonthlySummaries = 3,
MaxNotesPerFetch = 3,
DetailRequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(template);
var provider = _serviceProvider!;
_handler.Clear();
var planner = provider.GetRequiredService<CertCcSummaryPlanner>();
var plan = planner.CreatePlan(state: null);
Assert.NotEmpty(plan.Requests);
foreach (var request in plan.Requests)
{
_handler.AddJsonResponse(request.Uri, BuildSummaryPayload());
}
RegisterDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
foreach (var request in plan.Requests)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]);
Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]);
if (request.Month.HasValue)
{
Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]);
}
else
{
Assert.False(record.Metadata.ContainsKey("certcc.month"));
}
}
foreach (var uri in EnumerateDetailUris())
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]);
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
DocumentValue summaryValue;
Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue));
var summaryDocument = Assert.IsType<DocumentObject>(summaryValue);
Assert.True(summaryDocument.TryGetValue("start", out _));
Assert.True(summaryDocument.TryGetValue("end", out _));
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsDocumentArray.Count
: 0;
Assert.Equal(0, pendingNotesCount);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsDocumentArray.Count
: 0;
Assert.Equal(0, pendingSummariesCount);
Assert.True(state.Cursor.TryGetValue("lastRun", out _));
Assert.True(_handler.Requests.Count >= plan.Requests.Count);
foreach (var request in _handler.Requests)
{
if (request.Headers.TryGetValue("Accept", out var accept))
{
Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase);
}
}
}
private static string BuildSummaryPayload()
{
return $$"""
{
"count": 1,
"notes": [
"VU#{TestNoteId}"
]
}
""";
}
private void RegisterDetailResponses()
{
foreach (var uri in EnumerateDetailUris())
{
var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vendors.json"
: uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vuls.json"
: "vu-294418.json";
_handler.AddJsonResponse(uri, ReadFixture(fixtureName));
}
}
private static IEnumerable<Uri> EnumerateDetailUris()
{
var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
yield return new Uri(baseUri, $"{TestNoteId}/");
yield return new Uri(baseUri, $"{TestNoteId}/vendors/");
yield return new Uri(baseUri, $"{TestNoteId}/vuls/");
}
private async Task EnsureServiceProviderAsync(CertCcOptions template)
{
await DisposeServiceProviderAsync();
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = template.BaseApiUri;
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = template.SummaryWindow.WindowSize,
Overlap = template.SummaryWindow.Overlap,
InitialBackfill = template.SummaryWindow.InitialBackfill,
MinimumWindowSize = template.SummaryWindow.MinimumWindowSize,
};
options.MaxMonthlySummaries = template.MaxMonthlySummaries;
options.MaxNotesPerFetch = template.MaxNotesPerFetch;
options.DetailRequestDelay = template.DetailRequestDelay;
options.EnableDetailMapping = template.EnableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = template.BaseApiUri;
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = template.SummaryWindow.WindowSize,
Overlap = template.SummaryWindow.Overlap,
InitialBackfill = template.SummaryWindow.InitialBackfill,
MinimumWindowSize = template.SummaryWindow.MinimumWindowSize,
};
options.MaxMonthlySummaries = template.MaxMonthlySummaries;
options.MaxNotesPerFetch = template.MaxNotesPerFetch;
options.DetailRequestDelay = template.DetailRequestDelay;
options.EnableDetailMapping = template.EnableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
_serviceProvider = services.BuildServiceProvider();
}
private async Task DisposeServiceProviderAsync()
{
if (_serviceProvider is null)
{
return;
}
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
return File.ReadAllText(Path.Combine(baseDirectory, filename));
}
public Task InitializeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await DisposeServiceProviderAsync();
}
}
private async Task DisposeServiceProviderAsync()
{
if (_serviceProvider is null)
{
return;
}
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
return File.ReadAllText(Path.Combine(baseDirectory, filename));
}
public Task InitializeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await DisposeServiceProviderAsync();
}
}

View File

@@ -1,410 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
{
private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/");
private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri NovemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/11/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public CertCcConnectorSnapshotTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots()
{
var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero);
var harness = await EnsureHarnessAsync(initialTime);
RegisterSummaryResponses(harness.Handler);
RegisterDetailResponses(harness.Handler);
var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json");
var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json");
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json");
harness.TimeProvider.Advance(TimeSpan.FromMinutes(30));
RegisterSummaryNotModifiedResponses(harness.Handler);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var recordedRequests = harness.Handler.Requests
.Select(request => request.Uri.ToString())
.ToArray();
recordedRequests.Should().Equal(new[]
{
SeptemberSummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NoteDetailUri.ToString(),
VendorsDetailUri.ToString(),
VulsDetailUri.ToString(),
VendorStatusesDetailUri.ToString(),
YearlySummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NovemberSummaryUri.ToString(),
YearlySummaryUri.ToString(),
});
harness.Handler.AssertNoPendingResponses();
var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests);
WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json");
}
private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return _harness;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(45),
MinimumWindowSize = TimeSpan.FromDays(1),
};
options.MaxMonthlySummaries = 2;
options.MaxNotesPerFetch = 1;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = true;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler);
});
});
_harness = harness;
return harness;
}
private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider)
{
var documentStore = provider.GetRequiredService<IDocumentStore>();
var uris = new[]
{
SeptemberSummaryUri,
OctoberSummaryUri,
NovemberSummaryUri,
YearlySummaryUri,
NoteDetailUri,
VendorsDetailUri,
VulsDetailUri,
VendorStatusesDetailUri,
};
var records = new List<object>(uris.Length);
foreach (var uri in uris)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
if (record is null)
{
continue;
}
var lastModified = record.Headers is not null
&& record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader)
&& DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified)
? parsedLastModified.ToUniversalTime().ToString("O")
: record.LastModified?.ToUniversalTime().ToString("O");
records.Add(new
{
record.Uri,
record.Status,
record.Sha256,
record.ContentType,
LastModified = lastModified,
Metadata = record.Metadata is null
? null
: record.Metadata
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase),
record.Etag,
});
}
var ordered = records
.OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider)
{
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor ?? new BsonDocument();
BsonDocument? summaryDocument = null;
if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDoc)
{
summaryDocument = summaryDoc;
}
var summary = summaryDocument is null
? null
: new
{
Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null,
End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null,
};
var snapshot = new
{
Summary = summary,
PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null,
state.LastSuccess,
state.LastFailure,
state.FailCount,
state.BackoffUntil,
};
return SnapshotSerializer.ToSnapshot(snapshot);
}
private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider)
{
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = new List<Advisory>();
await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None))
{
advisories.Add(advisory);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests)
{
var ordered = requests
.OrderBy(static request => request.Timestamp)
.Select(static request => new
{
request.Method.Method,
Uri = request.Uri.ToString(),
Headers = new
{
Accept = TryGetHeader(request.Headers, "Accept"),
IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"),
IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"),
},
})
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static void RegisterSummaryResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero));
}
private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler)
{
AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\"");
AddNotModified(handler, NovemberSummaryUri, "\"certcc-summary-2025-11\"");
AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\"");
}
private static void RegisterDetailResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero));
AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero));
}
private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified)
{
var payload = ReadFixture(fixtureName);
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R"));
response.Content.Headers.LastModified = lastModified;
return response;
});
}
private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag)
{
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'.");
}
private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key)
=> headers.TryGetValue(key, out var value) ? value : null;
private static string? ToIsoString(BsonValue value)
{
return value.BsonType switch
{
BsonType.DateTime => value.ToUniversalTime().ToString("O"),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"),
_ => null,
};
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
var normalizedSnapshot = Normalize(snapshot);
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var path = GetWritablePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, normalizedSnapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, normalizedSnapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static string GetWritablePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static bool ShouldUpdateFixtures()
{
var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES");
return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase);
}
private static bool FixtureExists(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(primary))
{
return true;
}
var fallback = Path.Combine(baseDir, "Fixtures", filename);
return File.Exists(fallback);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}
public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
{
private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/");
private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri NovemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/11/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public CertCcConnectorSnapshotTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots()
{
var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero);
var harness = await EnsureHarnessAsync(initialTime);
RegisterSummaryResponses(harness.Handler);
RegisterDetailResponses(harness.Handler);
var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json");
var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json");
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json");
harness.TimeProvider.Advance(TimeSpan.FromMinutes(30));
RegisterSummaryNotModifiedResponses(harness.Handler);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var recordedRequests = harness.Handler.Requests
.Select(request => request.Uri.ToString())
.ToArray();
recordedRequests.Should().Equal(new[]
{
SeptemberSummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NoteDetailUri.ToString(),
VendorsDetailUri.ToString(),
VulsDetailUri.ToString(),
VendorStatusesDetailUri.ToString(),
YearlySummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NovemberSummaryUri.ToString(),
YearlySummaryUri.ToString(),
});
harness.Handler.AssertNoPendingResponses();
var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests);
WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json");
}
private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return _harness;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(45),
MinimumWindowSize = TimeSpan.FromDays(1),
};
options.MaxMonthlySummaries = 2;
options.MaxNotesPerFetch = 1;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = true;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler);
});
});
_harness = harness;
return harness;
}
private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider)
{
var documentStore = provider.GetRequiredService<IDocumentStore>();
var uris = new[]
{
SeptemberSummaryUri,
OctoberSummaryUri,
NovemberSummaryUri,
YearlySummaryUri,
NoteDetailUri,
VendorsDetailUri,
VulsDetailUri,
VendorStatusesDetailUri,
};
var records = new List<object>(uris.Length);
foreach (var uri in uris)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
if (record is null)
{
continue;
}
var lastModified = record.Headers is not null
&& record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader)
&& DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified)
? parsedLastModified.ToUniversalTime().ToString("O")
: record.LastModified?.ToUniversalTime().ToString("O");
records.Add(new
{
record.Uri,
record.Status,
record.Sha256,
record.ContentType,
LastModified = lastModified,
Metadata = record.Metadata is null
? null
: record.Metadata
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase),
record.Etag,
});
}
var ordered = records
.OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider)
{
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor ?? new DocumentObject();
DocumentObject? summaryDocument = null;
if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is DocumentObject summaryDoc)
{
summaryDocument = summaryDoc;
}
var summary = summaryDocument is null
? null
: new
{
Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null,
End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null,
};
var snapshot = new
{
Summary = summary,
PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsDocumentArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsDocumentArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null,
state.LastSuccess,
state.LastFailure,
state.FailCount,
state.BackoffUntil,
};
return SnapshotSerializer.ToSnapshot(snapshot);
}
private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider)
{
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = new List<Advisory>();
await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None))
{
advisories.Add(advisory);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests)
{
var ordered = requests
.OrderBy(static request => request.Timestamp)
.Select(static request => new
{
request.Method.Method,
Uri = request.Uri.ToString(),
Headers = new
{
Accept = TryGetHeader(request.Headers, "Accept"),
IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"),
IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"),
},
})
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static void RegisterSummaryResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero));
}
private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler)
{
AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\"");
AddNotModified(handler, NovemberSummaryUri, "\"certcc-summary-2025-11\"");
AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\"");
}
private static void RegisterDetailResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero));
AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero));
}
private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified)
{
var payload = ReadFixture(fixtureName);
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R"));
response.Content.Headers.LastModified = lastModified;
return response;
});
}
private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag)
{
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'.");
}
private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key)
=> headers.TryGetValue(key, out var value) ? value : null;
private static string? ToIsoString(DocumentValue value)
{
return value.DocumentType switch
{
DocumentType.DateTime => value.ToUniversalTime().ToString("O"),
DocumentType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"),
_ => null,
};
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
var normalizedSnapshot = Normalize(snapshot);
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var path = GetWritablePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, normalizedSnapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, normalizedSnapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static string GetWritablePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static bool ShouldUpdateFixtures()
{
var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES");
return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase);
}
private static bool FixtureExists(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(primary))
{
return true;
}
var fallback = Path.Combine(baseDir, "Fixtures", filename);
return File.Exists(fallback);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}

View File

@@ -1,474 +1,474 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorTests : IAsyncLifetime
{
private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public CertCcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().NotBeNull();
advisories.Should().HaveCountGreaterThan(0);
var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418");
advisory.Should().NotBeNull();
advisory!.Title.Should().ContainEquivalentOf("DrayOS");
advisory.Summary.Should().NotBeNullOrWhiteSpace();
advisory.Aliases.Should().Contain("VU#294418");
advisory.Aliases.Should().Contain("CVE-2025-10547");
advisory.AffectedPackages.Should().NotBeNull();
advisory.AffectedPackages.Should().HaveCountGreaterThan(0);
advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().Be(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task Fetch_PersistsSummaryAndDetailDocuments()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None);
summaryDocument.Should().NotBeNull();
summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
noteDocument.Metadata.Should().NotBeNull();
noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note");
noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418");
noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418");
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorsDocument.Metadata.Should().NotBeNull();
vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors");
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().NotBeNull();
vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vulsDocument.Metadata.Should().NotBeNull();
vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls");
var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None);
vendorStatusesDocument.Should().NotBeNull();
vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorStatusesDocument.Metadata.Should().NotBeNull();
vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsBsonArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummariesCount.Should().Be(0);
var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsBsonArray.Count
: 0;
pendingDocumentsCount.Should().Be(4);
var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappingsCount.Should().Be(0);
_handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri);
}
[Fact]
public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\"");
SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\"");
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.Clear();
SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\"");
SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\"");
_timeProvider.Advance(TimeSpan.FromMinutes(15));
await connector.FetchAsync(provider, CancellationToken.None);
var requests = _handler.Requests.ToArray();
requests.Should().OnlyContain(r =>
r.Uri == MonthlySummaryUri
|| r.Uri == YearlySummaryUri
|| r.Uri == NoteDetailUri
|| r.Uri == VendorsUri
|| r.Uri == VulsUri
|| r.Uri == VendorStatusesUri);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsBsonArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummaries.Should().Be(0);
var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
}
[Fact]
public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError);
var connector = provider.GetRequiredService<CertCcConnector>();
var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None));
Assert.True(failure is HttpRequestException || failure is InvalidOperationException);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.FailCount.Should().BeGreaterThan(0);
state.BackoffUntil.Should().NotBeNull();
state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow());
state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsBsonArray.Should().Contain(value => value.AsString == "294418");
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummaries.Should().Be(0);
}
[Fact]
public async Task Fetch_PartialDetailEndpointsMissing_CompletesAndMaps()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(
vulsStatus: HttpStatusCode.NotFound,
vendorStatusesStatus: HttpStatusCode.NotFound);
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().NotBeNull();
advisories!.Should().Contain(advisory => advisory.AdvisoryKey == "certcc/vu-294418");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().BeNull();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue).Should().BeTrue();
pendingDocsValue!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue).Should().BeTrue();
pendingMappingsValue!.AsBsonArray.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public sealed class CertCcConnectorTests : IAsyncLifetime
{
private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public CertCcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().NotBeNull();
advisories.Should().HaveCountGreaterThan(0);
var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418");
advisory.Should().NotBeNull();
advisory!.Title.Should().ContainEquivalentOf("DrayOS");
advisory.Summary.Should().NotBeNullOrWhiteSpace();
advisory.Aliases.Should().Contain("VU#294418");
advisory.Aliases.Should().Contain("CVE-2025-10547");
advisory.AffectedPackages.Should().NotBeNull();
advisory.AffectedPackages.Should().HaveCountGreaterThan(0);
advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().Be(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task Fetch_PersistsSummaryAndDetailDocuments()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None);
summaryDocument.Should().NotBeNull();
summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
noteDocument.Metadata.Should().NotBeNull();
noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note");
noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418");
noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418");
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorsDocument.Metadata.Should().NotBeNull();
vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors");
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().NotBeNull();
vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vulsDocument.Metadata.Should().NotBeNull();
vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls");
var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None);
vendorStatusesDocument.Should().NotBeNull();
vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorStatusesDocument.Metadata.Should().NotBeNull();
vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsDocumentArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummariesCount.Should().Be(0);
var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsDocumentArray.Count
: 0;
pendingDocumentsCount.Should().Be(4);
var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappingsCount.Should().Be(0);
_handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri);
}
[Fact]
public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\"");
SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\"");
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.Clear();
SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\"");
SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\"");
_timeProvider.Advance(TimeSpan.FromMinutes(15));
await connector.FetchAsync(provider, CancellationToken.None);
var requests = _handler.Requests.ToArray();
requests.Should().OnlyContain(r =>
r.Uri == MonthlySummaryUri
|| r.Uri == YearlySummaryUri
|| r.Uri == NoteDetailUri
|| r.Uri == VendorsUri
|| r.Uri == VulsUri
|| r.Uri == VendorStatusesUri);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsDocumentArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummaries.Should().Be(0);
var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
}
[Fact]
public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError);
var connector = provider.GetRequiredService<CertCcConnector>();
var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None));
Assert.True(failure is HttpRequestException || failure is InvalidOperationException);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.FailCount.Should().BeGreaterThan(0);
state.BackoffUntil.Should().NotBeNull();
state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow());
state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsDocumentArray.Should().Contain(value => value.AsString == "294418");
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummaries.Should().Be(0);
}
[Fact]
public async Task Fetch_PartialDetailEndpointsMissing_CompletesAndMaps()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(
vulsStatus: HttpStatusCode.NotFound,
vendorStatusesStatus: HttpStatusCode.NotFound);
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().NotBeNull();
advisories!.Should().Contain(advisory => advisory.AdvisoryKey == "certcc/vu-294418");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().BeNull();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue).Should().BeTrue();
pendingDocsValue!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue).Should().BeTrue();
pendingMappingsValue!.AsDocumentArray.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
[Fact]
public async Task ParseAndMap_SkipWhenDetailMappingDisabled()
{
await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false);
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().BeNullOrEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task ParseAndMap_SkipWhenDetailMappingDisabled()
{
await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false);
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
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);
advisories.Should().BeNullOrEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappings.Should().Be(0);
}
private async Task<ServiceProvider> BuildServiceProviderAsync(bool enableDetailMapping = true)
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/");
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
InitialBackfill = TimeSpan.FromDays(1),
MinimumWindowSize = TimeSpan.FromHours(6),
};
options.MaxMonthlySummaries = 1;
options.MaxNotesPerFetch = 5;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = enableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/");
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
InitialBackfill = TimeSpan.FromDays(1),
MinimumWindowSize = TimeSpan.FromHours(6),
};
options.MaxMonthlySummaries = 1;
options.MaxNotesPerFetch = 5;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = enableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
return services.BuildServiceProvider();
}
private void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"")
{
AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag);
AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag);
}
private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag)
{
AddNotModifiedResponse(MonthlySummaryUri, summaryEtag);
AddNotModifiedResponse(YearlySummaryUri, yearlyEtag);
}
private void SeedDetailResponses(
string detailEtag = "\"note-etag\"",
string vendorsEtag = "\"vendors-etag\"",
string vulsEtag = "\"vuls-etag\"",
string vendorStatusesEtag = "\"vendor-statuses-etag\"",
HttpStatusCode vendorsStatus = HttpStatusCode.OK,
HttpStatusCode vulsStatus = HttpStatusCode.OK,
HttpStatusCode vendorStatusesStatus = HttpStatusCode.OK)
{
AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag);
if (vendorsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag);
}
else
{
_handler.AddResponse(VendorsUri, () =>
{
var response = new HttpResponseMessage(vendorsStatus)
{
Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag);
return response;
});
}
if (vulsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag);
}
else
{
_handler.AddResponse(VulsUri, () =>
{
var response = new HttpResponseMessage(vulsStatus)
{
Content = new StringContent("vuls error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vulsEtag);
return response;
});
}
if (vendorStatusesStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag);
}
else
{
_handler.AddResponse(VendorStatusesUri, () =>
{
var response = new HttpResponseMessage(vendorStatusesStatus)
{
Content = new StringContent("vendor statuses error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorStatusesEtag);
return response;
});
}
}
private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag)
{
AddNotModifiedResponse(NoteDetailUri, detailEtag);
AddNotModifiedResponse(VendorsUri, vendorsEtag);
AddNotModifiedResponse(VulsUri, vulsEtag);
AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag);
}
private void AddJsonResponse(Uri uri, string json, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
var fallback = Path.Combine(baseDirectory, "Fixtures", filename);
return File.ReadAllText(fallback);
}
}
private void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"")
{
AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag);
AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag);
}
private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag)
{
AddNotModifiedResponse(MonthlySummaryUri, summaryEtag);
AddNotModifiedResponse(YearlySummaryUri, yearlyEtag);
}
private void SeedDetailResponses(
string detailEtag = "\"note-etag\"",
string vendorsEtag = "\"vendors-etag\"",
string vulsEtag = "\"vuls-etag\"",
string vendorStatusesEtag = "\"vendor-statuses-etag\"",
HttpStatusCode vendorsStatus = HttpStatusCode.OK,
HttpStatusCode vulsStatus = HttpStatusCode.OK,
HttpStatusCode vendorStatusesStatus = HttpStatusCode.OK)
{
AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag);
if (vendorsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag);
}
else
{
_handler.AddResponse(VendorsUri, () =>
{
var response = new HttpResponseMessage(vendorsStatus)
{
Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag);
return response;
});
}
if (vulsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag);
}
else
{
_handler.AddResponse(VulsUri, () =>
{
var response = new HttpResponseMessage(vulsStatus)
{
Content = new StringContent("vuls error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vulsEtag);
return response;
});
}
if (vendorStatusesStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag);
}
else
{
_handler.AddResponse(VendorStatusesUri, () =>
{
var response = new HttpResponseMessage(vendorStatusesStatus)
{
Content = new StringContent("vendor statuses error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorStatusesEtag);
return response;
});
}
}
private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag)
{
AddNotModifiedResponse(NoteDetailUri, detailEtag);
AddNotModifiedResponse(VendorsUri, vendorsEtag);
AddNotModifiedResponse(VulsUri, vulsEtag);
AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag);
}
private void AddJsonResponse(Uri uri, string json, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
var fallback = Path.Combine(baseDirectory, "Fixtures", filename);
return File.ReadAllText(fallback);
}
}

View File

@@ -1,6 +1,6 @@
using System;
using System.Globalization;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Storage;
@@ -90,7 +90,7 @@ public sealed class CertCcMapperTests
SourceName: "cert-cc",
Format: "certcc.vince.note.v1",
SchemaVersion: "certcc.vince.note.v1",
Payload: new BsonDocument(),
Payload: new DocumentObject(),
CreatedAt: PublishedAt,
ValidatedAt: PublishedAt.AddMinutes(1));

View File

@@ -1,58 +1,58 @@
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryParserTests
{
[Fact]
public void ParseNotes_ReturnsTokens_FromStringArray()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"VU#654321\"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#123456", "VU#654321" }, notes);
}
[Fact]
public void ParseNotes_DeduplicatesTokens_IgnoringCaseAndWhitespace()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"vu#123456\",\" 123456 \"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Single(notes);
Assert.Equal("VU#123456", notes[0], ignoreCase: true);
}
[Fact]
public void ParseNotes_ReadsTokens_FromObjectEntries()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[{\"id\":\"VU#294418\"},{\"idnumber\":\"257161\"}]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#294418", "257161" }, notes);
}
[Fact]
public void ParseNotes_SupportsArrayRoot()
{
var payload = Encoding.UTF8.GetBytes("[\"VU#360686\",\"VU#760160\"]");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#360686", "VU#760160" }, notes);
}
[Fact]
public void ParseNotes_InvalidStructure_Throws()
{
var payload = Encoding.UTF8.GetBytes("\"invalid\"");
Assert.Throws<JsonException>(() => CertCcSummaryParser.ParseNotes(payload));
}
}
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryParserTests
{
[Fact]
public void ParseNotes_ReturnsTokens_FromStringArray()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"VU#654321\"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#123456", "VU#654321" }, notes);
}
[Fact]
public void ParseNotes_DeduplicatesTokens_IgnoringCaseAndWhitespace()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"vu#123456\",\" 123456 \"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Single(notes);
Assert.Equal("VU#123456", notes[0], ignoreCase: true);
}
[Fact]
public void ParseNotes_ReadsTokens_FromObjectEntries()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[{\"id\":\"VU#294418\"},{\"idnumber\":\"257161\"}]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#294418", "257161" }, notes);
}
[Fact]
public void ParseNotes_SupportsArrayRoot()
{
var payload = Encoding.UTF8.GetBytes("[\"VU#360686\",\"VU#760160\"]");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#360686", "VU#760160" }, notes);
}
[Fact]
public void ParseNotes_InvalidStructure_Throws()
{
var payload = Encoding.UTF8.GetBytes("\"invalid\"");
Assert.Throws<JsonException>(() => CertCcSummaryParser.ParseNotes(payload));
}
}

View File

@@ -1,95 +1,95 @@
using System;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common.Cursors;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryPlannerTests
{
[Fact]
public void CreatePlan_UsesInitialBackfillWindow()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(120),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var plan = planner.CreatePlan(state: null);
Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start);
Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End);
Assert.Equal(3, plan.Requests.Count);
var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray();
Assert.Collection(monthly,
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(6, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri);
},
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(7, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri);
});
var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray();
Assert.Single(yearly);
Assert.Equal(2025, yearly[0].Year);
Assert.Null(yearly[0].Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri);
Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd);
}
[Fact]
public void CreatePlan_AdvancesWindowRespectingOverlap()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(10),
InitialBackfill = TimeSpan.FromDays(90),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var first = planner.CreatePlan(null);
var second = planner.CreatePlan(first.NextState);
Assert.True(second.Window.Start < second.Window.End);
Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}
using System;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common.Cursors;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryPlannerTests
{
[Fact]
public void CreatePlan_UsesInitialBackfillWindow()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(120),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var plan = planner.CreatePlan(state: null);
Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start);
Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End);
Assert.Equal(3, plan.Requests.Count);
var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray();
Assert.Collection(monthly,
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(6, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri);
},
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(7, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri);
});
var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray();
Assert.Single(yearly);
Assert.Equal(2025, yearly[0].Year);
Assert.Null(yearly[0].Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri);
Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd);
}
[Fact]
public void CreatePlan_AdvancesWindowRespectingOverlap()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(10),
InitialBackfill = TimeSpan.FromDays(90),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var first = planner.CreatePlan(null);
var second = planner.CreatePlan(first.NextState);
Assert.True(second.Window.Start < second.Window.End);
Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -1,31 +1,31 @@
using System.Linq;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcVendorStatementParserTests
{
[Fact]
public void Parse_ReturnsPatchesForTabDelimitedList()
{
const string statement =
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
"V2927/V2865/V2866\t4.5.1\n" +
"V2765/V2766/V2763/V2135\t4.5.1";
var patches = CertCcVendorStatementParser.Parse(statement);
Assert.Equal(11, patches.Count);
Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6");
Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1");
Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1"));
}
[Fact]
public void Parse_ReturnsEmptyWhenStatementMissing()
{
var patches = CertCcVendorStatementParser.Parse(null);
Assert.Empty(patches);
}
}
using System.Linq;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcVendorStatementParserTests
{
[Fact]
public void Parse_ReturnsPatchesForTabDelimitedList()
{
const string statement =
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
"V2927/V2865/V2866\t4.5.1\n" +
"V2765/V2766/V2763/V2135\t4.5.1";
var patches = CertCcVendorStatementParser.Parse(statement);
Assert.Equal(11, patches.Count);
Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6");
Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1");
Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1"));
}
[Fact]
public void Parse_ReturnsEmptyWhenStatementMissing()
{
var patches = CertCcVendorStatementParser.Parse(null);
Assert.Empty(patches);
}
}