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
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user